35-1-나.카드 설계

고스톱 게임은 여러 가지 객체들이 상호 작용하면서 운영되는 게임이다. 그래서 화투판의 각 실체들을 모델링하여 클래스로 표현하는 추상화 작업을 먼저 해야 한다. 이 작업을 하려면 실제로 담요 위에 화투를 펼쳐 놓고 게임을 하면서 각 사물이 어떤 특성을 가지며 어떤 행동을 하는지를 잘 분석해야 한다. 사람이 패를 섞어서 돌리고 플레이어가 카드를 내고 일치하는 카드를 먹으면서 게임을 진행하는 것을 코드로 흉내내기만 하면 게임이 완성되는 것이다. 그렇다면 실제 고스톱 게임판이 어떻게 생겼는지 생각해 보자. 추상화를 할 때는 사물의 특성을 잘 상상해 봐도 되지만 그보다는 실제 사물을 직접 보고 만지면서 가지고 놀아 봐야 한다.

이 사진속에 등장하는 모든 물체들이 프로그램에서는 다 객체가 된다. 실제 화투판이라면 여기에 재떨이, 맥주, 음료수 등도 필요하겠지만 게임과는 직접적인 상관이 없으므로 제외하도록 하자. 여담이지만 새벽에 혼자서 이 사진을 찍다가 아내에게 들켜 도대체 뭐하는 짓이냐고 핀잔을 들었었는데 어지간히도 황당했을 것이다.

전역 상수

이 예제는 줄 수가 500줄이 넘으므로 전체 소스를 한 번에 보이지 않고 매 클래스를 만들 때마다 관련 소스를 보이도록 한다. 물론 컴파일 가능한 전체 소스는 예제 쉘에 제공된다. 소스의 선두는 다음과 같이 되어 있다.

 

#include <Turboc.h>

#include <assert.h>

#include <iostream>

using namespace std;

 

const int MaxCard=48;

const int CardGap=4;

const int Speed=1000;

const int PromptSpeed=2000;

 

필요한 헤더 파일을 인클루드하고 네임 스페이스 선언을 한 후 4개의 전역 상수를 정의한다. 이 상수들은 게임판의 배치나 운영 방식을 조정하는데 #define으로 정의해도 되지만 C++스럽게 작성하기 위해 const 상수를 사용했다.

MaxCard는 화투의 총 개수인데 알다시피 화투는 48개의 카드로 구성되어 있다. 이 값을 바뀔리는 절대로 없으므로 보기 좋게 MaxCard라는 이름을 붙여 주었다. CardGap은 카드를 나열할 때의 간격인데 카드 하나는 3문자 길이를 가지므로 공백 하나를 고려하여 간격을 4로 정의했다. Speed와 PromptSpeed는 카드를 내거나 메시지를 출력할 때 대기할 시간이며 전체적인 게임 진행 속도를 조절한다.

카드

전역 상수 정의문 다음에는 게임에 등장하는 사물들을 클래스로 하나씩 정의한다. 먼저 SCard 클래스는 이 게임의 가장 원자적인 단위인 화투 한 장을 표현하는 클래스이다. 실제 이름은 화투라고 해야겠지만 카드라는 용어가 더 일반적이므로 SCard라는 이름을 붙였다.

 

// 화투 한장을 표현하는 클래스

struct SCard

{

     char Name[4];

     SCard() { Name[0]=0; }

     SCard(const char *pName) {

          strcpy(Name,pName);

     }

     int GetNumber() const {

          if (isdigit(Name[0])) return Name[0]-'0';

          if (Name[0]=='J') return 10;

          if (Name[0]=='D') return 11;

          return 12;

     };

     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 return 3;

     }

     friend ostream &operator <<(ostream &c, const SCard &C) {

          return c << C.Name;

     }

     bool operator ==(const SCard &Other) const {

          return (strcmp(Name,Other.Name) == 0);

     }

     bool operator <(const SCard &Other) const {

          if (GetNumber() < Other.GetNumber()) return true;

          if (GetNumber() > Other.GetNumber()) return false;

          if (GetKind() < Other.GetKind()) return true;

          return false;

     }

};

 

대부분의 동작이 간단하므로 분석하기 편하게 인라인으로 정의했다. 클래스가 아닌 구조체로 선언되어 있는데 구체적인 동작은 카드를 소유하는 데크나 플레이어에 정의되고 카드 자체에는 특별한 동작이 필요없기 때문이다. 물론 클래스로 선언하고 선두에 public:을 붙이면 똑같아지기는 하지만 이런 공개된 자료형에는 구조체가 더 어울리며 그래서 클래스 이름앞에 S를 붙여 구조체임을 분명히 했다.

카드의 속성으로는 숫자와 종류가 있다. 화투는 1~12까지 각 4장씩 총 48장의 카드로 구성되며 각 숫자의 카드에는 광, 십, 오, 피 네 종류가 포함된다. 숫자와 종류 외에 크기나 색깔, 두께가 달라지는 것은 아니므로 멤버 변수로는 이 두 가지 속성만 표현할 수 있으면 된다. 카드의 이름은 숫자 한 자리와 한글 한 글자로 구성되므로 널 문자까지 고려하여 크기 4의 고정 길이 문자 배열이면 충분하다. 그래서 SCard는 char형 배열 Name만을 멤버 변수로 가진다. 가변 길이는 이래 저래 다루기 귀찮은 문제가 많아 고정된 길이를 쓰는 것이 편리하다.

10번 이상의 카드는 통상 숫자를 부르지 않고 별도의 이름이 있는데 숫자에 1바이트만 할당되어 있으므로 적당한 알파벳 문자를 쓰기로 한다. 10번 카드는 장 또는 풍이라고 불리므로 알파벳 J를 쓰기로 했으며 12번 카드는 비라고 부르므로 알파벳 B가 적당하다. 11번 카드는 D라는 알파벳을 붙였는데 D가 무슨 뜻인지는 굳이 밝히고 싶지 않다. 말하지 않아도 무슨 뜻인지 대부분 알 수 있을텐데 지금 당신의 머리속에 떠오르는 그 뜻이 분명하다. 오짜리 카드는 흔히 '띠'라고 부르는데 텍스트 환경에서 껍데기를 의미하는 '피'와 글자가 잘 구분되지 않아 '오'라는 글자를 쓰기로 한다. 이 표현대로면 화투장에 다음처럼 이름이 붙는다.

SCard는 정적 배열 하나만을 멤버 변수로 가지며 동적 할당을 사용하지 않으므로 별도의 복사 생성자, 파괴자, 대입 연산자 따위를 필요로 하지 않는다. SCard 객체끼리는 그냥 대입하기만 하면 된다. 단, const char *로부터 초기화를 할 수 있도록 하기 위해 이 인수를 받는 생성자가 정의되어 있는데 문자열을 Name으로 복사만 한다. 이 생성자가 정의되어 있으면 화투패를 문자열 배열로 쉽게 초기화할 수 있다. 디폴트 생성자는 Name을 빈 문자열로 초기화하는데 빈 카드가 필요한 경우도 있어 일단 쓰레기만 치워 두었다. Name[0]가 NULL이면 이 카드는 빈 카드이다. 빈 카드는 카드를 섞을 때, 상대방의 피를 뺏들어 올 때 피가 없는 경우의 예외 처리를 위해 필요하다.

GetNumber, GetKind 함수는 카드의 숫자와 종류를 조사한다. 1~9사이의 숫자 카드는 숫자를 읽어 주고 J, D, B 등 10을 넘는 카드는 10, 11, 12를 리턴한다. 이 숫자는 카드 정렬에 사용되므로 순서에 맞게 조사해야 한다. 카드의 숫자는 Name의 첫 바이트에 있으므로 Name[0]만 읽으면 된다. 카드의 종류는 Name+1의 문자열을 읽어 광, 십, 오, 피 등과 비교한다. 카드의 종류도 정렬에 사용되므로 먼저 오는 카드를 낮은 숫자로 조사해야 한다. 이 두 함수는 카드끼리 종류가 같아 먹을 수 있는지, 먹은 패를 어디다 출력할지 등을 조사할 때 사용된다.

카드 출력과 비교를 위한 연산자들도 포함되어 있다. << 연산자는 카드를 화면으로 출력하는 프랜드 함수이다. ostream 객체(통상 cout)로 카드의 Name 멤버인 문자열을 출력하고 연쇄적인 출력을 위해 cout의 레퍼런스를 다시 리턴한다. == 연산자는 두 카드가 완전히 같은지 검사하는데 Name 멤버가 완전히 일치하면 같은 카드이다. < 연산자는 정렬을 위해 두 카드의 대소를 비교하는데 작은 카드가 더 앞쪽에 와야 한다. 숫자를 우선 비교해 보고 숫자가 더 작으면 true이고 크면 false이다. 숫자가 일치할 경우는 종류로 판별하는데 광이 가장 앞에 오고 십, 오, 피 순으로 온다. 두 카드를 비교하는 방법은 얼마든지 더 간단하게 구현할 수 있는데 다음 코드도 생각해 볼 수 있다.

 

     bool operator <(const SCard &Other) const {

          return (strcmp(Name,Other.Name) < 0);

     }

 

두 카드의 Name 문자열끼리 비교한 결과를 리턴해도 되는데 광, 십, 오, 피가 우연히 가나다순이기 때문에 종류로는 정확한 순서가 매겨지지만 앞의 숫자는 J, D, B가 알파벳순이 아니라 정확한 비교는 아니라고 할 수 있다. 다음 코드는 조금 꽁수가 섞여 있지만 정확하게 동작한다.

 

     bool operator <(const SCard &Other) const {

          return (GetNumber()*100+GetKind() < Other.GetNumber()*100+Other.GetKind());

     }

 

숫자와 종류를 일차원의 값으로 바꾸되 숫자에 우선권을 주기 위해서 100이라는 충분한 값을 곱하고 종류값을 더했다. 그래서 1광이 3피보다 훨씬 더 작아 앞쪽에 온다. 이때 곱하는 값 100은 4이상이면 효과가 동일한데 잘 동작하기는 하지만 왠지 어색해 보여 예제에서는 코드가 좀 길어지더라도 정석대로 비교했다.

SCard 클래스는 카드 한 장만을 표현하는데 실제 게임에서는 이런 카드 48장이 필요하다. 카드의 구성은 숫자마다 달라서 일정한 규칙이 없으므로 배열에 일일이 초기값을 나열하는 수밖에 없다. 1번 카드는 광이 있는 대신 십짜리 카드가 없지만 2번 카드는 광이 없는 대신 십짜리 카드가 있고 D번 카드는 피만 세 장 있어 카드 숫자별로 구성이 불규칙적이다. 이런 불규칙적인 값은 배열로 초기화하는 것이 가장 무난하다.

 

// 화투의 초기 카드 목록

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피"

};

 

크기 48의 SCard형 배열 HwaToo에는 화투를 종류별로 나열해 두었다. 초기값들이 문자열 상수로 표기되어 있는데 SCard 클래스가 const char *를 인수로 받아 Name에 복사하는 생성자를 정의하고 있으므로 이 배열 선언문이 제대로 동작한다. 이 배열을 데크에 무작위로 집어 넣으면 게임 준비가 완료되는 것이다.

카드셋

고스톱에는 뒤집을 카드를 쌓아 놓는 데크, 플레이어의 카드 패, 담요에 깔린 패 등 카드의 집합이 여러 개 나온다. CCardSet 클래스는 이런 카드의 집합을 관리하는 클래스이며 삽입, 삭제, 검색 등 집합을 관리하는 대부분의 동작을 제공한다. 실제 게임에 사용될 카드 집합은 이 클래스로부터 파생된다.

 

// 카드의 집합을 관리하는 클래스

class CCardSet

{

protected:

     SCard Card[MaxCard];

     int Num;

     const int sx,sy;

     CCardSet(int asx,int asy) : sx(asx), sy(asy) { Num=0; }

 

public:

     int GetNum() { return Num; }

     SCard GetCard(int idx) { return Card[idx]; }

     void Reset() {

          for (int i=0;i<MaxCard;i++) Card[i].Name[0]=0;

          Num=0;

     }

     void InsertCard(SCard C);

     SCard RemoveCard(int idx);

     int FindSameCard(SCard C,int *pSame);

     int FindFirstCard(const char *pName);

};

 

void CCardSet::InsertCard(SCard C) {

     int i;

 

     if (C.Name[0] == 0) return;

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

          if (C < Card[i]) break;

     }

     memmove(&Card[i+1],&Card[i],sizeof(SCard)*(Num-i));

     Card[i]=C;

     Num++;

}

 

SCard CCardSet::RemoveCard(int idx) {

     assert(idx < Num);

     SCard C=Card[idx];

     memmove(&Card[idx],&Card[idx+1],sizeof(SCard)*(Num-idx-1));

     Num--;

     return C;

}

 

int CCardSet::FindSameCard(SCard C,int *pSame) {

     int i,num;

     int *p=pSame;

 

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

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

              num++;

              *p++=i;

          }

     }

     *p=-1;

     return num;

}

 

int CCardSet::FindFirstCard(const char *pName) {

     int i;

 

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

          if (strstr(Card[i].Name,pName) != NULL) {

              return i;

          }

     }

     return -1;

}

 

먼저 멤버 변수의 구성을 보자. SCard의 객체 배열 Card를 크기 48로 선언하였다. CCardSet과 SCard는 포함 관계(HAS A)라고 할 수 있다. Num은 이 카드 집합의 현재 개수이며 sx, sy는 카드 집합을 그릴 화면상의 좌표값이되 좌표는 한 번 정해지면 다시 변경할 필요가 없으므로 상수로 선언했다. 이 멤버들은 외부에서 함부로 건드릴 수 없도록 보호되어 있되 파생 클래스에 대해서는 액세스를 허가하는 protected 액세스 속성을 가진다.

생성자는 화면 좌표를 인수로 전달받아 초기화 리스트에서 상수 멤버를 초기화하고 카드의 개수를 0으로 만들어 카드가 하나도 없는 상태로 집합을 생성한다. Card 객체 배열은 별도로 초기화하지 않아도 SCard 클래스의 디폴트 생성자가 Name의 쓰레기를 치우므로 모든 카드는 최초 빈 카드이다. 주의해서 볼 것은 CCardSet 클래스의 생성자가 protected 액세스 속성을 가져 외부로부터 은폐되어 있다는 점이다. 그래서 이 클래스는 직접 객체를 생성하지 못하며 파생 클래스를 통해서만 초기화될 수 있다.

GetNum, GetCard 멤버 함수는 보호된 멤버 변수를 대신 읽어 주는 액세스 함수이다. 대응되는 Set 함수가 없으므로 Card 배열과 Num은 외부에서 읽기만 할 수 있다. Reset 함수는 카드를 전부 빈 카드로 만들고 개수를 0으로 만들어 카드 집합을 텅텅 빈 상태로 재초기화한다. 이 예제에서는 당장 사용되지 않지만 게임을 여러 번 한다거나 조건에 맞지 않는 집합이 생성되었을 때 재초기화를 위해 미리 작성해 두었다.

나머지 멤버 함수들은 길이가 길고 자주 호출되기 때문에 내부에 인라인으로 정의하지 않고 외부에 정의했다. 카드 집합에 카드를 삽입, 제거하고 같은 숫자를 가지는 카드의 목록과 일치하는 카드를 검색하는 등 집합 관리에 꼭 필요한 기능을 제공한다. 먼저 카드를 삽입하는 InsertCard 함수를 보자. 이 함수는 카드를 정렬된 위치에 삽입하며 빈 카드는 삽입을 거부한다. 플레이어가 가지고 있는 패나 담요에 깔린 패를 한눈에 육안 검색할 수 있도록 하기 위해 정렬은 반드시 필요하다. 다음 예를 보자.

나열된 카드들 중 자신의 위치를 찾아 정확한 위치에 끼어들어야 정렬 상태를 계속 유지할 수 있다. InsertCard는 카드의 처음부터 Num까지 순회하면서 삽입할 카드와 집합내의 카드를 대소 비교하여 삽입할 카드보다 큰 최초의 카드를 찾아 그 위치에 삽입한다. 위 그림의 경우 7피가 삽입될 위치는 7피보다 최초로 큰 카드인 9피가 있는 자리이다. 카드끼리 어떻게 대소를 비교하는가는 SCard 클래스가 정의하고 있으므로 InsertCard는 < 연산자만 사용하면 된다. 위치가 선정되면 뒤쪽의 카드는 한칸씩 이동시키고 빈 자리에 새 카드를 삽입하며 개수를 1증가시킨다.

RemoveCard는 지정한 첨자의 카드를 읽어서 리턴하고 해당 카드를 집합에서 제거한다. 플레이어가 카드를 내거나 데크에서 카드를 한 장 뒤집을 때 이 함수가 호출될 것이다. 카드들은 정렬 상태를 유지해야 하며 임의 접근이 가능하기 위해서는 계속 인접해 있어야 하므로 제거된 카드 자리로 뒤쪽의 카드들을 한칸씩 앞쪽으로 이동시킨다. 카드 개수는 1 감소할 것이다. idx는 총 카드 개수보다 항상 더 작아야 하며 그렇지 않다면 잘못된 요청이므로 프로그램을 중지하도록 assert문을 사용했다. 삽입 삭제 함수가 만들어져 있으므로 집합간에 카드를 이동시키는 것은 다음 한 줄로 간단하게 처리할 수 있다.

 

B.InsertCard(A.RemoveCard(idx));

 

이 문장은 A의 idx번째 카드를 B로 옮긴다. B의 정렬된 위치에 정확하게 삽입할 것이며 두 집합의 개수나 정렬 상태는 항상 정확하게 유지된다. 클래스가 이런 세세한 동작을 정의하고 있으므로 main은 잡다한 일을 부품들에게 시키고 게임의 논리 구현에만 치중할 수 있는 것이다.

FindSameCard는 인수로 전달한 카드 C와 숫자가 일치하는 카드의 개수 및 첨자 목록을 조사한다. 플레이어가 패를 낼 때 먹을 게 있는지, 여러 장 있다면 어떤 카드가 일치하는지 등을 조사할 때 이 함수가 사용된다. 같은 숫자의 카드가 여러 개 깔릴 수 있으므로 플레이어가 낸 카드로 여러 카드 중 하나를 선택해서 먹어야 하는 상황이 자주 일어난다. 그래서 이 함수의 역할은 게임 운영에 아주 중요하다.

일치하는 카드의 개수는 리턴값으로 돌려지고 일치하는 목록은 arSame 정수형 배열에 앞쪽부터 순서대로 채워서 리턴된다. 일치하는 카드가 1장이라면 arSame[0]에 일치하는 카드의 첨자가 리턴되며 2장이라면 arSame[0], arSame[1]에 첨자 두 개가 리턴된다. 사용자가 낸 카드가 3광이었는데 담요에 깔려 있는 패가 다음과 같다고 해 보자.

 

1오 1피 3오 3피 5십 6피 9십 D광

 

담요에 3번 카드가 두 장 깔려 있으므로 3오, 3피 중 하나를 선택해서 먹을 수 있다. arSame에는 2, 3, -1이 들어가고 2가 리턴될 것이다. main은 이 함수의 조사 결과를 바탕으로 먹을 게 있는지, 설사를 했는지, 따닥이나 쪽인지 등을 판단한다. 이 함수는 개수와 목록 두 가지 정보를 한꺼번에 조사한다는 면에서는 편리하지만 정수형 배열을 참조 호출로 넘겨야 한다는 면에서는 다소 불편하다. 숫자가 같은 카드는 최대 4장이므로 끝 표식인 -1자리까지 포함해서 arSame은 최소한 크기 5 정도는 되어야 하며 이 함수를 부르기 위해 배열을 먼저 선언해 놔야 한다.

가변 길이의 목록을 조사하는 방법은 이 외에도 여러 가지가 있는데 발견된 모든 항목에 대해 특정한 작업을 해야 한다면 열거(Enumeration)라는 방법을 사용한다. 또는 개수를 먼저 조사하고 첨자 순서대로 목록을 개별적으로 조사하는 방법을 쓰기도 한다. 이 방법으로 FindSameCard 함수를 작성해 보면 다음과 같다.

 

int CCardSet::FindSameCard(SCard C,int idx)

{

     int i,num;

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

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

              if (idx == num) return i;

              num++;

          }

     }

     return num;

}

 

두 번째 인수 idx로 조사할 순서값을 지정하되 이 값이 범위 밖(예를 들어 -1)이면 개수를 리턴한다. 이 방법은 함수를 여러 번 호출해야 하므로 성능은 좋지 않지만 참조 호출없이 리턴값만으로 작업을 할 수 있다는 면에서는 오히려 더 편리하며 얼마든지 많은 목록도 조사할 수 있다는 이점이 있다. 이 예제의 경우 조사 대상 목록의 최대값이 분명히 정해져 있으므로 한 번의 호출로 개수와 목록을 다 조사하는 방법을 사용했다.

FindFirstCard 함수는 부분 문자열 검색을 통해 숫자나 종류가 일치하는 최초의 카드를 검색한다. 숫자와 종류를 동시에 주면 정확하게 일치하는 카드가 있는지 검사할 수도 있다. 조건에 맞는 카드가 발견되면 그 첨자를 리턴하고 없으면 -1을 리턴한다. 이 카드 집합에 5번 카드가 있는지, 광이 있는지 등을 조사할 수 있고 존재 자체만 조사할 때도 사용할 수 있다. 예를 들어 피박이나 광박의 경우 상대편에 피나 광이 하나도 없는지를 조사해야 하는데 이때 이 함수가 사용된다. 다음은 이 함수의 사용예이다.

 

FindFirstKind("피")            // 숫자에 상관없이 피가 있는지 조사

FindFirstKind("8")              // 종류에 상관없이 8이 있는지 조사

FindFirstKind("4십")          // 4십 카드가 있는지 조사

 

카드 집합은 카드의 크기 순으로 정렬되어 있으므로 이분 검색 기법을 사용하여 속도를 높일 수도 있다. 그러나 이 게임에서 카드 집합은 최고로 커봤자 48을 넘지 않으며 플레이어의 패는 10개 이하이므로 굳이 거창하게 이분 검색까지 동원할 필요가 없다. 순차 검색으로 처음부터 뒤져도 얼마든지 짧은 시간에 검색을 완료할 수 있으므로 간단한 논리를 사용하는 것이 좋다.

이상으로 카드와 카드의 집합을 클래스로 추상화했다. CCardSet은 고스톱의 카드들을 관리하는 가장 기본적인 동작을 정의하는데 게임 규칙이 추가되어 더 복잡한 관리가 필요하다면 이 클래스를 확장하면 된다. 인간 세상과 마찬가지로 부모 클래스가 많은 동작을 정의하면 자식들이 편해진다.