35-1-다.데크와 플레이어

게임을 구성하는 가장 원자적인 단위인 카드와 카드의 집합을 관리하는 클래스가 완성되었다. 하지만 이 둘은 실제 게임에 직접적으로 등장하지는 않는데 이제 게임에 바로 사용하는 실체들을 클래스로 만들어 보자. 이 단계에서는 화투판의 모양과 운용 규칙을 잘 상상해 가면서 클래스를 디자인해야 한다.

데크

데크는 카드를 쌓아 놓는 곳이며 흔히 담요의 중앙에 뒤집어서 놓여진다. 여기서 카드를 한장씩 꺼내 플레이어에게 나누어 주기도 하고 담요에도 패를 깔아 놓으며 위에서부터 순서대로 한장씩 뒤집어 가며 게임이 진행된다. 데크의 카드를 다 뒤집으면 게임이 종료된다. 데크는 카드의 집합이므로 CCardSet으로부터 상속받는다. 즉 CDeck과 CCardSet은 전형적인 IS A 관계라고 할 수 있으며 단순한 카드 집합에 비해 몇 가지 특수한 동작을 추가로 더 가진다.

 

// 담요 중앙에 카드를 쌓아 놓는 데크

class CDeck : public CCardSet

{

public:

     CDeck(int asx,int asy) : CCardSet(asx,asy) { ; }

     void Shuffle() {

          int i,n;

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

              do {

                   n=random(MaxCard);

              } while (Card[n].Name[0] != NULL);

              Card[n]=HwaToo[i];

              Num++;

          }

     }

     SCard Pop() { return RemoveCard(Num-1); }

     bool IsEmpty() { return Num==0; }

     bool IsNotLast() { return Num > 1; }

     void Draw(bool bFlip) {

          gotoxy(sx,sy);

          cout << "??? " << (bFlip ? Card[Num-1].Name:"   ");

     }

};

 

부모 클래스인 CCardSet에 카드 집합을 표현하기 위해 필요한 멤버들(Card, Num, sx, sy)이 모두 정의되어 있으므로 CDeck가 추가로 멤버를 가질 필요는 없다. 생성자는 출력될 위치만 전달받아 부모 클래스의 생성자를 호출하며 소유한 카드가 없는 빈 상태로 생성된다. 데크에 카드를 넣는 동작은 Shuffle 멤버 함수가 담당한다.

Shuffle은 카드를 추가하되 예측 불가능하도록 무작위로 카드를 배치한다. HwaToo 배열에 있는 카드들을 순서대로 한장씩 꺼내 데크의 임의 위치에 마구잡이로 집어 넣는 것이다. 이때 이미 카드를 넣은 위치에는 중복해서 넣지 말아야 하므로 난수로 위치를 고를 때 빈 자리인지를 반드시 점검해야 한다. 이 빈칸 판정을 위해 Card 배열의 모든 카드가 빈 카드로 초기화되어 있으며 그래서 SCard 클래스의 디폴트 생성자가 쓰레기를 치워 놓는 것이다.

부모 클래스인 CCardSet에는 카드를 삽입하는 InsertCard라는 함수가 이미 정의되어 있어 이 함수로 카드를 삽입할 수도 있다. 그러나 InsertCard는 카드를 순서대로 정렬해서 삽입하기 때문에 데크의 카드를 초기화하는 목적으로는 적합하지 않다. 데크의 카드들은 순서를 예측할 수 없어야 하므로 상속받은 InsertCard 함수를 쓸 수 없고 Shuffle이라는 별도의 함수로 초기화를 한다. HwaToo 배열에는 카드들이 마치 화투를 처음 산 것처럼 가지런히 정렬되어 있지만 데크에 들어갈 때는 마구 섞여서 들어간다.

데크는 일단 초기화되면 위에서부터 순서대로 카드를 한장씩 빼내기만 한다. 그래서 카드를 빼 내는 Pop 함수만 있고 다시 카드를 삽입하는 Push 함수는 필요가 없다. 고스톱 게임에서 데크에 카드를 다시 반납하는 경우는 절대로 없기 때문이다. Pop 함수는 CCardSet으로부터 상속받은 RemoveCard 함수로 Num-1번, 즉 마지막 카드를 한 장 제거하면서 이 카드를 리턴한다. 이렇게 빼낸 카드는 플레이어나 담요에게로 보내질 것이다.

데크가 카드를 섞고 한장씩 빼내는 과정은 실제로 우리가 고스톱 게임을 하는 과정을 그대로 모델링했다고 할 수 있다. 실게임에서는 카드들을 바닥에 놓고 마구 문지르거나 집어 든 상태에서 탁탁탁 치면서 초기화하는데 이 과정을 Shuffle이 그대로 흉내내는 것이다. 카드가 섞이면 다음으로 이 카드를 플레이어와 담요에 한장씩 배분하는데 이 동작은 Pop 함수가 담당한다.

IsEmpty와 IsNotLast 함수는 데크에 남아 있는 카드 개수를 조사한다. IsEmpty는 남은 카드 개수가 0일 때 true를 리턴하는데 게임 끝 판정을 위해 사용된다. 데크에 카드가 한 장도 남아 있지 않으면 승부에 상관없이 게임을 끝내야 한다. IsNotLast는 마지막 판인지 아닌지를 조사하는데 남은 카드가 1 초과, 즉 두 장 이상이라면 아직은 막판이 아니다. 설사, 싹쓸이, 따닥 등의 규칙들은 막판에는 전부 인정되지 않는데 이 함수로 막판 여부를 조사하여 이 규칙의 적용 여부를 조사한다. 막판에는 쪽이 아주 자주 일어나며 가장 마지막에는 항상 싹쓸이일 수밖에 없기 때문이다.

막판이 아니라는 조건은 데크가 비지 않았다는 조건과는 확실히 다르므로 !IsEmpty() 조건을 쓸 수는 없다. 처음 시작한 사람(선)이 마지막 카드를 둘 때는 데크에 아직 한 장이 남아 있으며 뒤에 시작한 사람이 마지막 카드를 둘 때는 데크가 비게 되는데 두 경우 모두 막판으로 인정해야 한다. 그래서 막판인지를 조사하는 별도의 함수를 만들어 두었다.

Draw 함수는 화면에 데크를 그린다. 데크는 카드를 여러 장 가지고 있기는 하지만 모두 포개져 있고 대개의 경우 뒤집어져 있으므로 카드를 일일이 출력할 필요없이 ???만 출력하면 된다. 단, 데크에서 카드 한장을 막 뒤집었을 때만 이 카드가 무엇인지 확인하기 위해 옆에 뒤집은 카드 한 장을 출력한다. Draw 함수의 bFlip인수는 제일 윗장을 뒤집어 보여줄 것인가 아닌가를 지정한다.

데크의 기능은 다른 것들에 비해 비교적 간단한 편이다. 출력 형태도 단순하고 카드를 무작위로 섞어서 가지고 있다가 Pop 요청이 있을 때마다 한 장씩 빼 주기를 게임이 끝날 때까지 반복하기만 하면 된다. 그래서 클래스 길이도 짧고 이해하기도 아주 쉽다.

플레이어

CPlayer는 게임을 하는 플레이어를 추상화한 클래스이다. 게임에 참여하는 사람은 최초 일정 개수의 카드(맞고의 경우 10장, 3명 고스톱의 경우 7장)를 받고 이 카드들을 한 장씩 내 가며 게임을 진행한다. CPlayer 클래스는 플레이어가 받은 카드의 집합을 관리한다. 플레이어도 카드를 여러 장 가지므로 CCardSet으로부터 상속받는다.

 

// 게임을 하는 플레이어

class CPlayer : public CCardSet

{

public:

     CPlayer(int asx,int asy) : CCardSet(asx,asy) { ; }

     void Draw(bool MyTurn) {

          int i,x;

          for (i=0,x=sx;i<Num;i++,x+=CardGap) {

              gotoxy(x,sy);

              cout << Card[i];

              if (MyTurn) {

                   gotoxy(x,sy+1);

                   cout << '[' << i+1 << ']';

              }

          }

     }

};

 

추가 멤버는 없고 생성자는 CDeck와 마찬가지로 위치만 초기화한다. 멤버 함수는 플레이어의 패를 그리는 Draw밖에 없다. 카드의 패를 순서대로 화면에 나열하되 자기 차례(MyTurn)일 때는 낼 카드를 입력받기 위해 각 카드 아래쪽에 일련 번호를 출력한다. 10개의 카드를 가지고 있을 때 Draw 함수의 출력 결과는 다음과 같다.

키보드로부터 낼 카드를 입력받아야 하기 때문에 일련 번호를 쓸 수밖에 없다. 자기 차례가 아닐 때는 선택 번호를 출력하지 않고 카드만 출력한다. 카드들은 삽입할 때부터 InsertCard에 의해 정렬되어 있으므로 순서대로 출력하기만 하면 오름차순으로 정렬된다.

담요

담요는 화면 중앙에 위치하며 실제로 게임이 진행되는 곳이다. 이 곳에 데크가 있고 펼쳐 놓은 카드들이 있는데 플레이어는 자신이 가진 카드중 담요의 카드와 일치하는 것을 내고는 두 카드를 먹는다. 담요의 특징을 잘 관찰해 보면 플레이어와 유사한 점을 많이 발견할 수 있는데 일정 개수의 카드를 가진다는 점이 동일하고 카드를 정렬해서 출력하는 방식도 동일하다. 그래서 CPlayer로부터 상속 받았다.

 

// 게임이 진행되는 담요

class CBlanket: public CPlayer

{

public:

     CBlanket(int asx,int asy) : CPlayer(asx,asy) { ; }

     void Draw() {

          CPlayer::Draw(false);

     }

     void DrawSelNum(int *pSame) {

          int n;

          int *p;

          for (n=1,p=pSame;*p!=-1;p++,n++) {

              gotoxy(sx+*p*CardGap,sy-1);

              cout << '[' << n << ']';

          }

     }

     void DrawTempCard(int idx,SCard C) {

          gotoxy(sx+idx*CardGap,sy+1);

          cout << C;

     }

};

 

그러나 담요가 직접 게임에 참여하는 것은 아니므로 사용자가 담요의 카드를 직접적으로 선택할 필요는 없다. 그래서 담요의 Draw 함수는 부모 클래스의 Draw 함수를 그대로 부르되 선택을 위한 일련 번호를 출력하지 않으며 MyTurn 인수는 항상 false이다.

대신 담요는 플레이어에 비해 두 가지 출력이 더 필요하다. 사용자가 카드를 냈을 때 담요에 숫자가 일치하는 카드가 두 개 있다면 둘 중 어떤 카드를 먹을 것인지를 선택받아야 한다. 카드를 선택하는 방법이 키보드뿐이므로 일치하는 카드에 대해서만 일련 번호를 출력해야 한다. DrawSelNum 함수가 이 출력을 담당한다. 예를 들어 8이 두 장 깔려 있는 상태에서 플레이어가 8을 냈다면 어떤 카드를 먹고 싶은지 선택받아야 하는데 이를 위해 8번 카드 위쪽에 일련 번호를 출력한다.

플레이어는 이 두 카드 중 먹고 싶은 카드의 일련 번호를 누른다. 데크에서 뒤집은 카드의 경우도 마찬가지 처리가 필요하다.

플레이어가 카드를 먹으면 낸 카드를 잠시 담요에 올려 놓아야 한다. 이 카드가 확실히 플레이어의 것이 될 것인가 아닌가는 데크의 카드까지 뒤집어 봐야 알 수 있으므로 아직 먹은 것으로 확정해서는 안된다. 잘 알다시피 고스톱에는 설사라는 규칙이 있어 먹은 걸 도로 내 놔야 하는 경우도 있다. 그래서 임시적으로 플레이어의 카드를 담요에 잠시 그려 놓는데 이 처리는 DrawTempCard 함수가 담당한다. 예를 들어 깔려 있는 5오 카드를 5피 카드를 내서 먹었다면 화면에 다음과 같이 표시된다.

별 다른 일이 없으면 이 두 카드를 플레이어가 먹게 된다는 표식이다. 이런 중간 과정을 출력하지 않으면 플레이어는 게임이 어떻게 진행되고 있는지를 명확히 알지 못할 것이다.

플레이어패

CPlayerPae 클래스는 플레이어가 게임중에 먹은 패와 점수를 관리한다. CPlayer가 먹은 패를 관리하도록 모델링할 수도 있지만 게임에 참여하는 카드와 이미 먹은 카드는 실체가 다르므로 별도의 클래스로 분리하는 것이 합리적이다. 카드의 집합인 것은 동일하므로 CCardSet으로부터 상속받는다.

 

// 플레이어가 먹은 카드의 집합

class CPlayerPae : public CCardSet

{

private:

     int nGo;

 

public:

     int OldScore;

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

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

     int GetGo() { return nGo; }

     void IncreaseGo() { nGo++; }

     void Draw();

     SCard RemovePee();

     int CalcScore();

};

 

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) {

              gotoxy(x[kind],sy+kind);

              x[kind]+=CardGap;

          } else {

              gotoxy(x[3],py);

              x[3]+=CardGap;

              if (x[3] > 75) {

                   x[3]=sx;

                   py++;

              }

          }

          cout << Card[i];

     }

     gotoxy(sx+20,sy);

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

}

 

SCard CPlayerPae::RemovePee() {

     int idx;

 

     idx=FindFirstCard("피");

     if (idx != -1) {

          return RemoveCard(idx);

     }

     return SCard();

}

 

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();

          n[kind]++;

     }

     NewScore=gscore[n[0]];

     if (n[0] == 3 && FindFirstCard("B광") != -1) NewScore--;

     if (n[1] >= 5) NewScore += (n[1]-4);

     if (n[2] >= 5) NewScore += (n[2]-4);

     if (n[3] >= 10) NewScore += (n[3]-9);

     if (FindFirstCard("8십")!=-1 && FindFirstCard("5십")!=-1 && FindFirstCard("2십")!=-1) NewScore += 5;

     if (FindFirstCard("1오")!=-1 && FindFirstCard("2오")!=-1 && FindFirstCard("3오")!=-1) NewScore += 3;

     if (FindFirstCard("4오")!=-1 && FindFirstCard("5오")!=-1 && FindFirstCard("7오")!=-1) NewScore += 3;

     if (FindFirstCard("9오")!=-1 && FindFirstCard("J오")!=-1 && FindFirstCard("6오")!=-1) NewScore += 3;

     return NewScore;

}

 

상속받은 카드의 집합 외에도 이전 점수와 고 회수를 멤버로 가지는데 이전 점수인 OldScore는 외부에서도 자주 참조하고 점수가 추가로 발생했을 때 갱신해야 하므로 공개되어 있지만 고 회수는 반드시 1씩 증가해야 한다는 규칙이 있어 숨겨져 있다. 외부에서는 GetGo함수로 현재 고 회수를 조사하거나 또는 IncreaseGo 함수로 고 회수를 증가시킬 수만 있다. 한꺼번에 두 번 고는 게임 규칙상 불가하며 고 회수가 감소하는 일도 없다.

생성자는 위치를 초기화하고 OldScore는 6점으로, 고 회수는 0번으로 초기화한다. OldScore가 6점으로 초기화되는 이유는 맞고에서 최초로 고, 스톱을 선택할 수 있는 최소 점수가 7점이기 때문이다. main에서는 현재 점수와 이전 점수를 비교해 보고 이전 점수보다 더 많은 점수를 획득했을 때만 고, 스톱을 선택할 기회를 제공한다. 3점이나 5점은 얻어봐야 아직 기본이 안되므로 점수를 좀 더 모아야 하는데 이 비교를 위해 OldScore는 최소 기본 점수보다 하나 더 작은 점수로 초기화되어 있는 것이다.

또 고스톱에는 이런 규칙이 있다. 고를 했는데 상대방이 피를 뺏들어가 점수가 줄어 들었다면 뺏긴 점수를 벌충하고 추가 점수를 더 내야만 고, 스톱을 선택할 수 있다. 고를 한 상황에서 추가 점수를 내지 못하고 상대방이 먼저 스톱을 해 버리면 이 상황을 독박이라고 하며 함부로 고를 부르지 못하도록 하는 역할을 한다. OldScore를 유지하는 것은 이 규칙을 위해서이기도 한데 이전의 최고 점수를 가지고 있어야 다음 고, 스톱 기회를 부여할 시점을 정확하게 판단할 수 있다.

Draw 함수는 먹은 패를 화면에 출력하는데 카드 종류별로 보기 좋게 출력한다. 0부터 Num 직전까지 루프를 돌며 집합내의 카드를 적당한 위치에 뿌리는데 실제 고스톱퍼(GoStoper)들이 담요에 먹은 패들을 정렬하는 방식을 비슷하게 흉내냈다. 플레이어나 담요의 카드와는 달리 수가 아주 많을 수 있으므로 여러 줄에 나누어 출력해야 하며 이왕이면 정렬까지 하면 더 보기 좋다. Draw 함수의 출력 결과는 다음과 같다.

출력의 기준 좌표는 일단 (sx, sy)인데 이 좌표를 좌상단으로 하여 광, 십, 오가 각각 한 줄씩 출력되고 피는 여러 줄에 출력될 수 있다. 광은 많이 모아 봤자 다섯 개밖에 안되므로 짜투리 공간을 활용하여 점수와 고 회수까지도 그 옆에 출력한다. 카드 집합은 종류로 정렬되어 있지 않고 숫자 우선으로 정렬되어 있으므로 어떤 종류의 카드가 언제 나올 지는 알 수 없다. 그래서 4 종류의 x좌표를 각각 유지하기 위해 int x[4] 배열을 선언했으며 최초 sx에서 시작했다가 카드가 나올 때마다 CardGap만큼 오른쪽으로 이동한다.

, 십, 오는 한 줄에 모두 출력되므로 y좌표가 sy, sy+1, sy+2로 고정적이지만 피는 여러 줄에 출력될 수 있기 때문에 py라는 변수로 y좌표를 별도 관리한다. 피가 일정 개수 이상 출현했으면 다음 줄에 계속 출력하는데 이를 위해 py는 1 증가하고 x[3]은 다시 sx로 돌아가 왼쪽 아래에서부터 다시 출력한다. 일종의 개행을 하는 것이다. 이 함수는 길이가 그다지 길지는 않지만 C코드의 수준으로 치면 중급 이상의 난이도를 가지고 있어 패턴을 잘 기억해 둘만하다.

RemovePee 함수는 피 한 장을 제거한다. 고스톱에는 상대방이 세 장을 한꺼번에 드시거나 쪽, 따닥 등을 했을 때 피를 상납하는 규칙이 있으므로 먹은 걸 도로 내 놔야 할 필요도 있다. 단, 줄 게 없으면 어쩔 수 없이 간절히 주고 싶어도 뺏기지 않아도 상관없다. 피를 주려면 일단 피가 있는지 찾아 봐야 하는데 이때는 CCardSet 함수의 FindFirstCard 함수로 "피"라는 문자열을 가진 카드가 집합 내에 있는지를 보면 된다.

있다면 그 카드를 제거함과 동시에 리턴되어 나오는 카드를 다시 리턴하면 된다. 이렇게 리턴된 카드는 상대방의 먹은 패에 삽입될 것이다. 만약 없다면 SCard()를 리턴하는데 이 구문은 SCard의 디폴트 생성자로 임시 카드를 생성하는 문장이다. SCard의 디폴트 생성자가 빈 카드를 만들도록 되어 있으므로 이 리턴문은 곧 배째라는 뜻이며 InsertCard 함수 선두에서 빈 카드이면 아무 것도 하지 않도록 되어 있다.

CalcScore 함수는 이름이 의미하듯이 점수를 계산하는데 고스톱 게임의 점수 규칙대로 먹은 카드 수와 종류에 따라 계산하면 된다. 먼저 각 종류별로 점수가 부여되므로 종류별로 몇 장이나 모았는지 n 배열에 개수를 수집한다. 끝까지 루프를 돌며 n 배열에서 GetKind 가 리턴하는 첨자의 요소를 증가시키기만 하면 된다. 광은 모은 개수에 따라 점수가 차등 부여되는데 3장이면 3점, 4장이면 4점, 5장이면 15점이다. 이 점수표를 gscore 룩업 테이블에 작성해 놓고 n[0]로 선택하면 광점수를 바로 구할 수 있다. 단, B광이 포한된 삼광은 2점으로 계산해야 한다.

광점수를 먼저 계산한 후 십, 오, 피의 초과 개수 기준으로 점수를 증가시킨다. 십, 오는 5장부터 1점, 피는 10장부터 1점으로 계산했다. 다음은 특정 카드들을 모았을 때의 점수를 더하는데 청단, 홍단, 초단, 고도리가 있다. FindFirstCard로 검색하여 이 카드들이 모두 존재하면 정해진 점수를 각각 더한다. 최종적으로 구해진 점수를 리턴하면 점수 계산이 완료된다.

여기까지 여러 가지 클래스들을 제작해 왔는데 클래스간의 계층 관계를 그림으로 그려 보면 다음과 같다. 상용 라이브러리에 비해 아주 간단한 구조로 되어 있다.

게임을 바라보는 시각에 따라 이와는 다른 디자인이 나올 수도 있다. 이 계층을 잘 정리해 놓고 다음 실습 단계로 넘어가도록 하자.