35-1-마.개작

만들어진 소스를 차근 차근히 분석해 보면 어렵지 않게 이해는 될 것이다. 그러나 설계부터 구현, 디버깅까지 직접 한 것은 아니므로 분석이 된다고 해서 유사한 게임을 바로 만들 수 있는 것은 아니다. 실제 제작 과정에서는 예상치 못한 문제에 봉착하기도 하고 처음 생각했던 방법을 완전히 버리고 다시 만들어야 하는 경우도 빈번하다. 그러나 문제를 푸는 패턴들을 경험해 보았고 객체 지향 문법들이 어떻게 적용되는지를 체험해 보았으므로 응용력이 다소 향상되었을 것이다.

그럼 이제 분석한 소스의 이해를 바탕으로 응용력을 발휘하여 개작 실습을 해 보자. 첫 버전은 개작의 여지를 남겨 놓기 위해 일부러 몇 가지 기능을 제외해 두었다. 앞의 소스를 읽어온 후 반드시 직접 뜯어 고쳐 가면서 이 기능들을 실습해 보자. 만약 개작 실습도 그냥 읽고 코드를 바로 보면 별로 얻을게 없어진다. 시행 착오를 거쳐야만 경험이 쌓이고 성취감을 느낄 수 있다.

4장 금지

플레이어의 패에 같은 카드 4장이 한꺼번에 들어오면 총통이라고 하며 기본 점수를 주고 게임은 그대로 종료된다. 만약 담요에 4장의 같은 카드가 깔리면 이 카드는 처음 패를 두는 사람(선)이 다 가진다. 이 규칙은 게임을 재미있게 하거나 고득점을 위해 존재하는 것이 아니라 그대로 두면 판이 돌지 않기 때문에 어쩔 수 없이 마련된 것일 뿐이다. 4장이 한쪽에 몰린 채로 게임을 진행하면 재미가 없어지므로 다시 하자는 뜻이다.

담요에 깔린 4장을 처음 두는 사람에게 다 주는 처리는 게임 초기화의 일부이며 플레이어가 개입할 여지가 없다. 패를 펼치자 마자 상대방이 4장을 벌써 가져 가 버리고 바닥에 4장밖에 없으면 얼마나 김이 빠지겠는가? 그래서 이 기능은 구현하지 말고 한꺼번에 같은 패가 4장 들어오지 않도록 금지 처리만 하도록 하자. 이 기능을 구현하려면 카드셋에 한꺼번에 들어온 같은 카드가 몇 장인지 조사할 수 있는 기능이 필요하다. CCardSet에 다음 멤버 함수를 추가한다.

 

int CCardSet::GetMaxSeries() {

     int i,n,m,old=-1;

 

     for (i=0,n=1,m=1;i<Num;i++) {

          if (old == Card[i].GetNumber()) {

              n++;

              m=max(n,m);

          } else {

              n=1;

              old = Card[i].GetNumber();

          }

     }

     return m;

}

 

카드패 전체를 순회하면서 연속되는 숫자의 최대값을 찾는다. 카드셋은 삽입할 때부터 정렬되고 같은 숫자들은 연속되어 있으므로 최대 연속값을 검색하는 것은 비교적 쉽다. 각 패에 이 함수를 호출해 보면 총통과 흔들기 조건을 쉽게 점검할 수 있다. 패를 섞는 초기화 함수는 다음처럼 수정한다.

 

void Initialize()

{

     int i;

 

     for (;;) {

          Deck.Reset();

          South.Reset();

          North.Reset();

          Blanket.Reset();

          Deck.Shuffle();

          for (i=0;i<10;i++) {

              South.InsertCard(Deck.Pop());

              North.InsertCard(Deck.Pop());

              if (i < 8) Blanket.InsertCard(Deck.Pop());

          }

          if (South.GetMaxSeries()!=4 && North.GetMaxSeries()!=4 && Blanket.GetMaxSeries()!=4)

              break;

     }

}

 

양쪽 플레이어와 담요에 최대 연속 카드가 4장 미만이 될 때까지 섞기와 배분을 다시 반복한다. 이 기능을 위해 카드셋을 리셋하는 함수를 미리 만들어 두었다. 4장이 한 패에 몰리는 현상은 흔하지 않으므로 왠만하면 한 번에 성공할 것이고 기껏해야 두 번 반복하면 제대로 섞일 것이다.

흔들기

같은 카드를 세 장 받은 플레이어는 흔들기를 할 수 있다. 흔들기는 무조건 적용되는 것이 아니라 플레이어가 게임을 시작하기 전에 선언을 해야 하는데 점수가 곱절이 되는 이익이 있기는 하지만 상대방에게 패의 일부를 공개해야 한다는 점에서는 불이익이 있기 때문이다. 흔들기는 게임의 흐름에 영향을 주지는 않으며 게임 종료 후 점수를 계산할 때만 효력을 발휘하므로 선언의 의미 이상은 없다. 흔들었다는 것을 어딘가에 기록해 놓기만 하면 된다. 점수와 관련된 클래스는 CPlayerPae이므로 여기에 멤버 변수를 추가한다.

 

class CPlayerPae : public CCardSet

{

private:

     int nGo;

 

public:

     int OldScore;

    int bShake;

    CPlayerPae(int asx,int asy) : CCardSet(asx,asy) { OldScore=6;nGo=0;bShake=false; }

    void Reset() { CCardSet::Reset();OldScore=6;nGo=0;bShake=false; }

     ....

 

bool형의 bShake 멤버 변수를 추가했으며 생성자와 Reset 함수에서 false로 초기화한다. 패를 분배한 직후에 같은 카드가 세 장인지 살펴보고 플레이어에게 흔들기를 할 건지 질문을 한다. 단, 질문을 하기 전에 어떤 카드가 세 장인지를 알아야 하므로 DrawScreen을 호출하여 화면을 먼저 그리도록 했다. 흔들기 가능 여부는 앞에서 만들어 놓은 GetMaxSeries 함수만 호출해 보면 쉽게 판별할 수 있다.

 

void main()

{

     ....

     randomize();

     Initialize();

    DrawScreen();

    if (South.GetMaxSeries() == 3) {

        ch=InputInt("같은 카드가 세 장입니다. (1:흔들기, 2:그냥 하기) ",1,2);

        if (ch == 1) SouthPae.bShake=true;

    }

    if (North.GetMaxSeries() == 3) {

        ch=InputInt("같은 카드가 세 장입니다. (1:흔들기, 2:그냥 하기) ",1,2);

        if (ch == 1) NorthPae.bShake=true;

    }

     for (SouthTurn=true;!Deck.IsEmpty();SouthTurn=!SouthTurn) {

     ....

 

플레이어가 흔들겠다는 의사를 밝히면 bShake를 true로 설정한다. 만약 상대방이 다른쪽 컴퓨터에서 게임을 하는 네트워크 환경이라면 상대방에게도 어떤 카드로 흔들기를 했는지 알려야 할 것이다. 이 경우 GetMaxSeries 함수는 어떤 카드가 세 장인지를 조사하는 기능이 추가되어야 한다. 그러나 이 게임의 경우 같은 컴퓨터에서 상대방 패를 뻔히 다 들여다 보고 하는 게임이라 이 기능은 작성하지 않았다. Draw에서는 흔들었다는 사실을 출력해 놓는다.

 

void CPlayerPae::Draw() {

     ....

     gotoxy(sx+20,sy);

     cout << "점수:" << CalcScore() << "점, " << nGo << "고 "

          << (bShake ? "흔듬":"");

}

 

프로그램은 흔들었다는 표식만 할 뿐이지 판돈을 두 배 징수하는 것까지는 처리하지 않는다. 이 게임은 판돈의 개념이 아예 없는 친선 게임일 뿐이므로 판돈을 계산하는 것은 컴퓨터 바깥의 플레이어들이 알아서 해야 한다.

흔들기와 비슷한 개념으로 폭탄이라는 것도 있는데 세 장을 한꺼번에 내는 것이며 흔들기와 마찬가지로 점수는 두 배가 된다. 이 기능도 구현하자면 어렵지 않지만 폭탄을 하면 플레이어 카드가 한꺼번에 세 장이 사라지므로 이 후 두 번은 카드를 내지 않고 데크만 뒤집을 수 있도록 해야 한다. 숫자키로만 대화하는 현재의 방식에서는 구현하기에 무리가 따르므로 이 기능은 구현하지 않기로 한다.

쌍피 인정

고스톱에는 쌍피 기능이 있어 D피, B피는 피 두 장으로 취급한다. 그래서 이 카드는 다른 어떤 카드보다 더 탐이 나는 카드이며 하나만 먹어도 왠지 배가 부른 느낌이 들어 플레이어를 기분 좋게 한다. 9십 카드도 선택에 따라 쌍피로 사용할 수 있고 어떤 화투에는 쓰리피까지 있는데 이런 기능은 일단 제외하고 두 개의 쌍피 기능만 구현해 보자.

쌍피는 일반 피와는 점수 계산 방식이 분명히 다르고 또 플레이어가 볼 때도 일반 피와는 구분되어야 한다. D 카드는 일반 피와 쌍 피가 같이 있으므로 담요에 두 장이 깔렸을 때 어떤 카드가 쌍피인지 알 수 있어야 플레이어가 합리적인 선택을 할 수 있다. 그래서 일반 피로 취급하되 점수를 계산할 때만 2장으로 취급하는 꽁수로는 문제를 깔끔하게 해결할 수 없으며 쌍피는 아예 별도의 카드 종류로 취급해야 한다. 쌍피는 카드 종류에 '쌍'이라는 글자를 부여하고 GetKind 함수가 이 종류의 카드도 인식하도록 수정한다.

 

struct SCard

{

     int GetKind() const {

          if (strcmp(Name+1,"광")==0) return 0;

          else if (strcmp(Name+1,"십")==0) return 1;

          else if (strcmp(Name+1,"오")==0) return 2;

        else if (strcmp(Name+1,"쌍")==0) return 3;

        else return 4;

     }

     ....

 

쌍피의 값을 일반피보다 더 작게 함으로써 정렬할 때 쌍피가 일반피보다 더 앞쪽에 오도록 했다. GetKind가 두 종류의 피를 구분하므로 < 연산자는 수정할 필요없으며 따라서 카드를 정렬하는 모든 함수들도 별도의 수정없이 쌍피를 인식할 수 있게 된다. 화투 구성표를 수정하여 두 개의 카드를 쌍피로 만든다.

 

SCard HwaToo[MaxCard]={

     "1광","1오","1피","1피","2십","2오","2피","2피","3광","3오","3피","3피",

     "4십","4오","4피","4피","5십","5오","5피","5피","6십","6오","6피","6피",

     "7십","7오","7피","7피","8광","8십","8피","8피","9십","9오","9피","9피",

    "J십","J오","J피","J피","D광","D쌍","D피","D피","B광","B십","B오","B쌍"

};

 

"D쌍", "B쌍"이라는 카드 두 개가 새로 생겼다. 플레이어와 담요는 카드 문자열을 있는 그대로 출력하기만 하므로 별다른 영향을 받지 않는다. 먹은 패는 카드 종류별로 출력 위치가 달라지는데 쌍피와 일반피를 같이 출력하기 위해 약간의 수정이 필요하다. Draw 함수에 다음 조건문 하나만 추가한다.

 

void CPlayerPae::Draw() {

     int i,kind;

     int x[4]={sx,sx,sx,sx},py=sy+3;

 

     for (i=0;i<Num;i++) {

          kind=Card[i].GetKind();

        if (kind >= 3) kind=3;

          if (kind < 3) {

          ....

 

GetKind로 조사한 카드 종류가 3, 4번일 경우 둘 다 3번으로 강제 조정함으로써 두 종류의 카드가 3번 줄에 나타나도록 했다. 점수를 계산하는 함수도 약간 수정해야 하는데 생각보다는 간단하다.

 

int CPlayerPae::CalcScore() {

     int i,kind,n[4]={0,};

     int NewScore;

     static int gscore[]={0,0,0,3,4,15};

 

     for (i=0;i<Num;i++) {

          kind=Card[i].GetKind();

        if (kind == 3) n[kind]++;

        if (kind >= 3) kind=3;

          n[kind]++;

     }

     ....

 

쌍피가 나타나면 피 개수를 일단 한 번 증가시켜 놓는다. 그리고 일반피와 쌍피를 모두 3번으로 만들어 두고 원래의 코드를 실행한다. 쌍피는 중복해서 계산되므로 나타날 때마다 두 장의 피가 있는 것으로 계산될 것이다. 그 외 달라져야 할 부분은 없다. 보다시피 조금만 궁리해 보면 약간의 손질만으로도 원하는 목적을 달성할 수 있다.

점수를 계산하는 방법이나 피를 출력하는 방법은 그다지 어렵지 않다. 그러나 쌍피의 개념이 들어감으로써 골치 아파지는 문제가 있는데 바로 상대방의 피를 가져오는 부분이다. 쌍피의 개념이 없을 때는 뺏어올 개수만큼 제거하고 내 패에 삽입하기를 반복하면 그만이다. 피가 없으면 빈 카드라도 던져줌으로써 에러 처리까지 자연스럽고 완벽하게 수행했었다.

그러나 쌍피가 들어가면 한장씩 꺼내 삽입하는 방식을 쓸 수 없다. 뺏어올 피의 개수에 따라 조합이 다양할 수 있다는 것이 첫 번째 문제고 피의 개수가 실제 이동되는 카드의 개수와도 맞지 않다는 것이 두 번째 문제이다. 이런 문제가 발생하는 근본적인 이유는 쌍피를 반씩 쪼갤 수 없기 때문인데 고스톱 규칙에 피 한장을 가져올 때 상대방이 쌍피밖에 없다면 쌍피를 그냥 가져오도록 되어 있다. 거스름피를 준다거나 하는 규칙은 없다. 피 3 장을 요구했을 때 가진 피에 따라 다음 규칙대로 피를 내 줘야 한다.

세 장의 피를 가지고 있다면 이대로 주면 된다. 쌍피 하나와 일반피 두 장이 있다면 쌍피를 먼저 주고 피 한장을 줘야 한다. 피 두 장을 일단 줘 버리고 더 줄게 없어서 쌍피를 뺏긴다면 이는 무척 어리석은 행동이 될 것이다. 쌍피만 두 장 있다면 이때는 어쩔 수 없이 둘 다 줄 수밖에 없지만 일반피도 하나 있다면 쌍피 하나를 주지 않아도 상관없다. 요구한 피보다 더 작은 수만 있다면 이때는 어쩔 수 없이 죄다 털어 줘야 한다. 이 예에서 보다시피 요구가 들어왔을 때 어떤 피를 우선적으로 내 줄 지 결정하는 일반적인 규칙을 찾기가 쉽지 않다.

두 번째 문제는 뺏어오는 피의 수가 카드의 수와 다를 수 있다는 점인데 그래서 RemovePee가 제거한 카드를 곧바로 InsertCard로 상대방의 패에 삽입하는 편리한 방법을 쓸 수 없다. 결국 이 문제를 풀려면 RemovePee 함수가 제거할 피 개수에 맞게 최소 비용의 카드 조합을 선택하는 능력이 있어야 하며 선택된 조합의 카드 개수만큼 InsertCard 함수를 따로 호출해야 한다. RemovePee 함수는 원형도 바뀌고 본체도 왕창 바뀐다.

 

int CPlayerPae::RemovePee(int n,SCard *pCard) {

     int ns=0,np=0,tp;

     int i,idx,num=0;

     SCard *p=pCard;

 

     for (i=0;i<Num;i++) {

          if (Card[i].GetKind() == 3) ns++;

          if (Card[i].GetKind() == 4) np++;

     }

     tp=ns+np;

     if (tp == 0) return 0;

 

     switch (n) {

     case 1:

          if (np != 0) {

              *p++=RemoveCard(FindFirstCard("피"));

              return 1;

          } else {

              *p++=RemoveCard(FindFirstCard("쌍"));

              return 1;

          }

          break;

     case 2:

          if (ns != 0) {

              *p=RemoveCard(FindFirstCard("쌍"));

              return 1;

          } else {

              *p++=RemoveCard(FindFirstCard("피"));

              num=1;

              if (np >= 2) {

                   *p++=RemoveCard(FindFirstCard("피"));

                   num=2;

              }

              return num;

          }

     case 3:

          i=RemovePee(2,p);

          p+=i;

          idx=RemovePee(1,p);

          return i+idx;

     default:

          return 0;

     }

}

 

요구하는 피 개수를 첫 번째 인수로 전달받고 선택된 카드의 목록은 두 번째 배열에 채우며 리턴값으로 배열에 몇 개의 카드가 들어갔는지를 돌려준다. 호출측에서는 이 함수의 리턴값만큼 루프를 돌며 InsertCard를 호출할 것이다. 아주 일반적으로 작성하려면 요구된 개수에 따라 쌍피와 일반피의 우선 순위를 정하고 개수를 채우거나 피가 바닥날 때까지 퍼 주는 코드를 작성해야 한다. 이 방법은 가능하기는 하지만 실제로 코드를 작성해 보면 상상 이상으로 복잡한 알고리즘을 요구한다. 그래서 일반화를 포기하고 요구 수의 최대값이 3밖에 안된다는 점을 활용하여 사람의 사고 방식과 유사한 코드를 작성했다.

이 함수의 코드는 상당히 쉬운 편이라 읽으면 말로 그대로 풀이가 된다. 먼저 ns에 쌍피의 개수, np에 일반피의 개수를 구해 놓고 tp에 피의 전체 개수를 구해 놓는다. 만약 tp가 0이면 줄 게 없으므로 배째라는 의미로 0을 리턴한다. 줄 게 있다면 요구된 개수에 따라 개별적으로 처리한다. 1개의 피를 주어야 한다면 먼저 일반피가 있는지 살펴보고 있다면 그 카드를 배열에 채우고 1을 리턴한다. 쌍피보다는 일반피가 우선이다. 아마 대부분의 경우가 여기에 해당될텐데 피 한 장을 던져 주며 옛다 가져가라 하는 식이다.

만약 일반피가 없다면 쌍피가 있다는 얘기이므로 else문에 쌍피의 존재 여부를 점검할 필요는 없다. 여기까지 왔다는 것은 tp가 0이 아니라는 조건이 성립되어서 쌍피나 일반피 중 적어도 하나는 있음을 확신할 수 있다. 이때는 아까워도 이 쌍피를 줄 수밖에 없다. 리턴값은 여전히 1인데 쌍피지만 상대편 카드에 삽입할 카드는 한 장뿐이다. 실제 고스톱 게임중에 내가 피 한장을 줘야 할 상황이라면 정확하게 이 순서대로 판단 및 행동을 하는데 사람의 동작을 코드로 그대로 옮긴 것이다.

피 두 장을 요구할 때는 쌍피를 우선적으로 보고 쌍피가 있으면 한장만 던져 준다. 쌍피가 없다면 일반피를 주되 일단 한 장은 무조건 있으므로 한 장을 주고 더 줄게 있는지 점검한 후 있다면 주고 없다면 어쩔 수 없이 한 장만 준 채로 그냥 리턴한다. 피 세 장을 줄 때의 처리는 상대적으로 간단하다. 두 장 먼저 주고 한 장을 더 주면 된다. 일종의 재귀 호출인 셈인데 한 장, 두 장을 주는 논리가 확실하므로 두 호출을 조합하기만 하면 된다. 4장 이상은 실제로는 발생할 수 없으므로 0을 리턴하여 요구를 묵살한다.

RemovePee 함수가 여러 장의 피를 한꺼번에 제거하고 그 조합을 배열에 채워 리턴하므로 뺏은 카드를 옮기는 부분도 수정되어야 한다. main의 피 이동 코드를 다음과 같이 완전히 재작성한다. 참조 호출을 하므로 최소한 크기 3 이상의 SCard 지역 배열이 선언되어 있어야 한다.

 

SCard arPee[3];

int nPee;

 

     nPee=OtherPae->RemovePee(nSnatch,arPee);

     for (i=0;i<nPee;i++) {

          TurnPae->InsertCard(arPee[i]);

     }

 

RemovePee를 호출하여 nSnatch만큼 상대방의 피를 제거하고 arPee에 기록된 카드를 리턴된 개수만큼 자신의 패에 삽입한다. 피 2 장을 요구했을 때 제거되는 카드 개수가 반드시 2가 아니므로 RemovePee가 리턴하는 카드 개수만큼만 삽입해야 한다. 코드를 만들었으면 이 코드가 제대로 동작하는지 테스트해야 하는데 피가 이동되는 상황은 자주 발생하지 않기 때문에 테스트하기가 아주 어렵다. 이럴 때는 임시 테스트 코드를 작성하여 잘 이동하는지 점검해 본다.

 

sprintf(Mes,"내고 싶은 화투를 선택하세요(1~%d,0:종료) ",Turn->GetNum());

ch=InputInt(Mes,0,Turn->GetNum());

if (ch >= 1 && ch <= 3) {

     nPee=OtherPae->RemovePee(ch,arPee);

     for (i=0;i<nPee;i++) {

          TurnPae->InsertCard(arPee[i]);

     }

     SouthTurn = !SouthTurn;

     continue;

}

 

1~3까지의 키 입력을 피를 강제로 뺏어오는 코드로 잠시 용도를 바꾸어 피가 잘 이동하는지 테스트해 봤다. 문제가 있다면 수정하고 테스트가 끝나면 이 코드는 삭제한다.

피박, 광박, 독박 판별

다음은 피박과 광박 기능을 추가해 보자. 이긴쪽이 피로 점수를 획득했는데 진쪽의 피가 5장 미만일 때를 피박이라고 한다. 피박을 당한 플레이어는 상대방의 점수를 2배해서 판돈을 물어야 하므로 판을 키우는 역할을 하며 게임의 긴장도를 높이는 아주 재미있는 규칙이다. 단, 상대방이 피를 하나도 얻지 못했다면 이때는 예외적으로 피박이 아니라는 규칙이 있다. 그런데 원치 않아도 피가 들어오는 경우가 있어 피없이 피박을 면하는 것도 쉽지가 않다.

광박은 이긴 쪽이 광으로 점수를 획득했는데 진쪽은 광을 하나도 가지지 못했을 때이다. 독박은 점수를 더 얻을 수 있을 것 같아 고를 불렀는데 추가 점수 획득에 실패한 경우이다. 세 규칙 모두 게임 진행에는 영향을 미치지 않으며 게임이 끝난 후 판돈을 계산할 때만 영향을 미치므로 게임이 끝난 후 루프 바깥에서 프로그램 종료 직전에 판별만 하면 된다. 판돈을 곱절로 받는 것은 플레이어가 알아서 할 일이다. for 루프 바깥에 다음 코드를 작성한다.

 

     CPlayer *LastGo=NULL;

 

     for (SouthTurn=true;!Deck.IsEmpty();SouthTurn=!SouthTurn) {

          ....

              if (InputInt("추가 점수를 획득했습니다.(0:스톱, 1:계속)",0,1)==1) {

                   TurnPae->OldScore=NewScore;

                   TurnPae->IncreaseGo();

               LastGo=Turn;

              } else {

                   break;

              }

     }

     DrawScreen();

 

     // 승부와 피박, 광박, 독박 여부를 판정한다.

     bool SouthWin;

     int SouthScore,NorthScore;

     int TurnPee=0,TurnLight=0,OtherPee=0,OtherLight=0;

 

     if (Deck.IsEmpty()) {

          if (LastGo != NULL) {

              SouthWin = (LastGo == &North);

          } else {

              SouthScore=SouthPae.CalcScore();

              NorthScore=NorthPae.CalcScore();

              if (SouthScore < 7 && NorthScore < 7) {

                   OutPrompt("양쪽 모두 기본 점수를 얻지 못해 비겼습니다.");

                   return;

              }

              SouthWin=(SouthScore > NorthScore);

          }

     } else {

          SouthWin=SouthTurn;

     }

     sprintf(Mes,"%s군이 이겼습니다. ", SouthWin ? "남":"북");

 

     if (SouthWin) {

          TurnPae=&SouthPae;

          OtherPae=&NorthPae;

     } else {

          TurnPae=&NorthPae;

          OtherPae=&SouthPae;

     }

     for (i=0;i<TurnPae->GetNum();i++) {

          if (TurnPae->GetCard(i).GetKind() >= 3) TurnPee++;

          if (TurnPae->GetCard(i).GetKind() == 0) TurnLight++;

     }

     for (i=0;i<OtherPae->GetNum();i++) {

          if (OtherPae->GetCard(i).GetKind() >= 3) OtherPee++;

          if (OtherPae->GetCard(i).GetKind() == 0) OtherLight++;

     }

 

     if (TurnPee >= 10 && OtherPee < 5 && OtherPee != 0) {

          strcat(Mes,"진쪽이 피박입니다. ");

     }

     if (TurnLight >= 3 && OtherLight == 0) {

          strcat(Mes,"진쪽이 광박입니다. ");

     }

     if (OtherPae->GetGo() != 0) {

          strcat(Mes,"진쪽이 독박입니다. ");

     }

     OutPrompt(Mes);

}

 

for 루프를 탈출하여 게임이 끝나는 경로는 한쪽에서 스톱을 부른 경우, 데크의 패가 떨어진 경우 두 가지이다. 스톱을 했을 때는 스톱한 플레이어가 바로 승자이므로 별 어려움없이 승패를 가름할 수 있다. 데크의 카드가 떨어졌을 때는 누가 승자인지 조사해야 하는데 이 규칙도 간단하지 않다. 최후로 고(Go)를 부른 플레이어가 추가 점수를 얻기 전에 데크가 비었다면 이때는 고를 부른 플레이어가 무조건 패한다. 이 조건을 점검하기 위해 플레이어가 고를 부를 때마다 해당 플레이어를 LastGo 변수에 저장한다.

점수를 한 번도 얻지 못했는데 데크가 비었다면 이때는 자동 스톱된 경우이므로 마지막 점수를 비교해 본다. 양쪽 어느 누구도 기본 점수인 7점을 얻지 못했다면 나가리이다. 더 이상 계산해 볼 것도 없이 비겼다는 결과를 출력하고 프로그램을 종료한다. 한쪽이 막판에 기본 점수를 획득했다면 승자가 있는 게임이다. 그리고 승자가 누구인지 그 결과를 문자열로 조립해 둔다. 이 문자열 뒤쪽에 피박, 광박, 독박 등의 판정 결과가 덧붙여진다.

이긴쪽의 패를 TurnPae, 진쪽을 OtherPae 포인터에 미리 대입해 놓는다. 그리고 다음은 피박, 광박 점검을 위해 양쪽의 피, 광 개수를 센다. 이긴쪽의 피가 10장 이상인데 진 쪽은 5장 미만의 피가 존재하면 이 때가 피박이다. 이긴 쪽의 광이 세 장 이상인데 진쪽에 광이 없으면 광박이다. 진쪽이 고를 부른 적이 있음에도 불구하고 승자가 되지 못했다면 이 플레이어는 독박이다. 게임의 최종 결과는 Mes 문자열에 조립되어 출력되며 프로그램은 종료된다.

다형성 활용

이 예제는 C++의 클래스를 이용하여 고스톱판의 실체들을 추상화하며 각 클래스는 실체 표현을 위해 속성과 동작을 잘 캡슐화하고 있다. 적절히 자신의 멤버를 숨기는 정보 은폐 기능도 십분 활용하여 외부에서 객체를 함부로 조작하지 못하도록 스스로 방어도 한다. 또한 각 클래스에 공통되는 기능을 부모 클래스에 정의하고 파생 클래스는 이 기능들을 상속받아 사용하며 부모와 동작이 조금 틀리다면 멤버 함수를 재정의하기도 한다.

객체 지향 프로그래밍 기법들을 가급적 많이 활용해 보고자 노력했는데 안타깝게도 이 예제에는 다형성이 등장하지 않는다. 가상 함수를 쓰는 부분이 전혀 없는데 클래스의 수가 많지 않고 객체 포인터를 쓰기는 하지만 부모 타입으로 자식을 가리킬 경우가 없어 동적 결합이 꼭 필요한 상황도 없다. 사실 다형성은 이 정도 규모의 프로그램에는 등장하지 않으며 좀 더 대규모의 클래스 계층이 형성되어야 제 기능을 발휘한다.

이 예제에 다형성을 억지로라도 구현해 볼만한 부분을 찾아 본다면 Draw 함수를 들 수 있다. CCardSet 클래스에 Draw 순수 가상 함수를 선언하고 파생 클래스는 이 함수를 재정의한다. 그리고 CCardSet 파생 클래스의 객체들을 CCardSet *의 배열에 집어 넣어 놓고 DrawScreen 함수가 루프를 돌며 배열 내의 각 객체에 대해 Draw만 열심히 불러 대는 것이다. 그러나 아무리 예제라 하더라도 너무 억지스러운 것 같고 각 클래스의 Draw 원형을 강제로 맞춰야 하므로 제외했다.

테스트

고스톱 게임의 규칙은 결코 간단하지 않으며 어떤 규칙은 좀체 발생하지 않는다. 그래서 혼자 게임을 테스트해 보기가 굉장히 어렵다. 학습용 예제일 뿐인데 이 게임을 혼자서 한다는 것은 정신 상태를 의심받을만큼 지루한 일이다. 그렇다고 해서 테스트를 생략할 수는 없는데 이럴 때는 테스트도 컴퓨터에게 맡길 수 있다. 자동화된 테스트를 하도록 다음과 같이 예제를 잠시 변경해 보자. 먼저 테스트 속도 향상을 위해 대기 시간을 최소로 한다.

 

const int Speed=0;

const int PromptSpeed=0;

 

그리고 main 함수를 수정하여 난수의 시작점을 루프로 돌려 각 난수에 대해 게임이 잘 실행되는지를 차례대로 점검해 본다.

 

void main()

{

     ....

     randomize();

    for (int k=0;k<1000;k++) {

    srand(k);

     Initialize();

     for (SouthTurn=true;!Deck.IsEmpty();SouthTurn=!SouthTurn) {

          ....

     }

     OutPrompt(Mes);

    gotoxy(40,22);

    if (Blanket.GetNum() != 0) {

        printf("%d 난수번에 이상이 있음",k);

        getch();

    } else {

        printf("%d번 테스트 완료",k);

        delay(500);

    }

    SouthPae.Reset();

    NorthPae.Reset();

    }

}

 

k 루프가 난수 발생기를 매번 다른 값으로 초기화하면서 게임을 돌려 보는 것이다. 게임 중간에 사용자의 입력이 필요한 부분이 있는데 사용자의 응답을 처리하는 InputInt에서 무조건 1을 리턴하도록 수정한다.

 

int InputInt(const char *Mes, int range)

{

     return 1;

 

항상 첫 번째 카드를 선택하고 고, 스톱 질문에도 항상 고를 선택하도록 했다. 이렇게 자동 응답 시스템을 만들어 놓으면 컴퓨터가 혼자 패섞어서 돌리고 혼자 게임을 진행할 것이다. 그것도 엄청나게 빠른 속도로 말이다. 게임을 끝까지 진행해서 담요에 남은 패가 하나도 없다면 일단 게임은 규칙대로 잘 돌아간다고 할 수 있다. 짝이 맞는 카드를 잘 찾아서 제대로 가져가며 설사한 것도 잘 처리하고 있는 것이다. 만약 담요의 카드가 남은 채로 게임이 끝났다면 이때는 뭔가 논리에 잘못이 있다는 뜻이다.

이상이 발견되면 실행을 멈추고 몇 번 난수의 시작점에서 이상이 있는지를 출력한다. 이 번호를 srand의 인수로 주고 게임을 천천히 실행해 보면 어디서 이상이 있는지 점검하여 수정할 수 있다. 또는 메시지가 뜨기 전에 프로그램이 다운된다거나 하는 경우도 적발해 낼 수 있다. 이 상태로 한 시간 정도만 테스트를 돌려보면 프로그램에 이상이 있는지 아닌지 거의 완벽하게 점검될 것이다.

개작 과제

이상으로 고스톱 게임 제작과 몇 가지 개작 실습까지 해 봤는데 짧지 않은 실습이었다. 그럼에도 불구하고 더 실습을 해 보고 싶은 사람은 다음 개작 실습까지 해 보자. 이상의 개작까지 완료하면 거의 완전한 고스톱 게임이 된다.

 

첫 설사시 기본 점수를 부여하는 규칙이 있다.

세 번 설사시도 기본 점수로 게임이 끝난다.

③ 개작예에서도 재껴 놓은 폭탄 기능도 구현해 보자.

9십 카드는 쌍피와 십짜리 카드 양쪽으로 활용된다.

 

콘솔에서 만들 수 있는 게임은 이 정도이며 다음에 그래픽을 배우면 좀 더 품질 높은 게임으로 만들 수 있을 것이다. 마우스로 패를 선택할 수 있으며 실제 화투장이 돌아 다니고 패를 낼 때 그럴듯한 소리도 낼 수 있다. 그리고 네트워크까지 붙이면 둘이서 또는 셋이서 각자의 집에서 게임을 즐길 수 있는 정말 해 볼만한 게임이 된다. 이쯤되면 판돈의 개념도 들어가야 할 것이다. 진짜 돈은 아니더라도 뭔가 왔다 갔다 해야 재미가 있는 법이다.

아직은 좀 이르겠지만 경험이 많이 쌓이면 컴퓨터에 인공 지능을 부여하여 컴퓨터와 둘이서 맞고를 할 수도 있을 것이다. 환경이 달라지고 게임의 진행 방식에 변화가 생기면 불가피하게 수정해야 하는 부분도 있지만 콘솔에서 만든 예제의 논리는 거의 그대로 재사용되므로 차후에 네트워크 고스톱 게임을 만들어 볼 생각이 있다면 이 예제를 부지런히 분석하고 실습해 보기 바란다.