35-1-라.게임 운영

이제 게임을 만들기 위한 모든 클래스들이 다 만들어졌으므로 이 부품들을 잘 조립해서 서로 카드를 주거니 받거니 하면서 게임이 원활하게 진행되도록 해 보자. 클래스는 부품으로서 해야 할 고유의 속성과 동작을 잘 정의하고 있지만 고스톱 게임 규칙까지 구현하는 것은 아니므로 main에서 규칙대로 부품이 동작하도록 총 지휘를 해야 한다. 클래스는 어디까지나 게임의 재료를 표현할 뿐이며 main이 어떻게 규칙을 운영하느냐에 따라 이 프로그램은 고스톱이 될 수도 있고 민화투가 될 수도 있고 화투점이 될 수도 있다.

전역변수

클래스는 어디까지나 타입일 뿐이므로 인스턴스를 만들어야 사용할 수 있다. 게임에 필요한 객체들은 다음과 같다. 대부분 화투판에 직접적으로 등장하는 실체들이다. 게임 전반에 걸쳐 참조되는 변수들이므로 main 이전에 전역변수로 선언한다.

 

CDeck Deck(18,9);

CPlayer South(5,20), North(5,1);

CBlanket Blanket(5,12);

CPlayerPae SouthPae(40,14), NorthPae(40,4);

bool SouthTurn;

 

Deck는 화투를 쌓아 놓는 곳이며 플레이어는 남군(South), 북군(North) 두 명이 있고 중간에는 담요(Blanket)가 위치한다. 각 플레이어는 게임중에 패를 따먹는데 SouthPae, NorthPae객체가 먹은 패를 관리한다. 각 객체들은 모두 화면상에 자신들이 배치될 위치만 인수로 전달받으며 나머지 초기화는 게임 시작 직후에 수행된다. 콘솔 환경은 80*24의 크기를 가지는데 화면의 적당한 곳에 객체들을 보기 좋게 배치했다. 배치 상태를 바꾸고 싶다면 전역변수 선언문의 인수들만 조정하면 된다.

객체가 아닌 유일한 전역변수는 차례를 표현하는 SouthTurn 밖에 없다. 고스톱은 플레이어들이 번갈아 가면서 한 번씩 카드를 두는 턴 방식의 게임이므로 지금이 누구 차례인지를 기억해야 한다. 이 예제는 두 명이 게임을 진행하는 맞고이며 남군 또는 북군 차례 중 하나이므로 bool형의 변수 하나면 누구 차례인지를 알 수 있다.

도우미 함수

게임 진행은 main이 하지만 혼자서 반복되는 잡다한 작업을 다 할 수는 없으므로 몇 가지 일반적인 동작에 대해서는 도우미 함수를 두고 필요할 때마다 이 함수들을 호출한다. 도우미는 main의 부담을 덜어 주는 역할을 하는데 동작은 간단한 편이다. 다음 4개의 도우미 함수를 작성한다. 코드를 읽어 보면 알겠지만 한결같이 길이가 짧고 착하게 생겼다.

 

void Initialize()

{

     int i;

 

     Deck.Shuffle();

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

          South.InsertCard(Deck.Pop());

          North.InsertCard(Deck.Pop());

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

     }

}

 

void DrawScreen()

{

     clrscr();

     South.Draw(SouthTurn);

     North.Draw(!SouthTurn);

     Blanket.Draw();

     Deck.Draw(false);

     SouthPae.Draw();

     NorthPae.Draw();

}

 

void OutPrompt(const char *Mes,int Wait/*=0*/)

{

     gotoxy(5,23);

     for (int i=5;i<79;i++) { cout << ' '; }

     gotoxy(5,23);

     cout << Mes;

     delay(Wait);

}

 

int InputInt(const char *Mes, int start, int end)

{

     int ch;

 

     OutPrompt(Mes);

     for (;;) {

          ch=tolower(getch());

          if (ch == 0xE0 || ch == 0) {

              ch=getch();

              continue;

          }

          if (!(isdigit(ch) || ch=='a')) continue;

          if (ch=='a') ch=10; else ch=ch-'0';

          if (ch >= start && ch <= end) {

              return ch;

          }

          OutPrompt("무효한 번호입니다. 지정한 범위에 맞게 다시 입력해 주세요.");

     }

}

 

Initialize 함수는 게임을 초기화하는데 데크의 패를 무작위로 섞은 후 플레이어와 담요로 카드를 분배한다. 플레이어는 각 10장씩의 카드를 받고 담요는 8장의 카드를 받는다. 데크의 카드가 무작위로 섞여 있으므로 분배되는 카드도 예측 불가능하다. 그래서 분배 순서는 전혀 중요하지 않다. 남군, 북군에 한꺼번에 10장씩 주나 남군 1장, 북군 1장씩 10번을 주나 마찬가지라는 얘기다. 분배가 완료되면 곧바로 게임이 시작된다.

카드를 섞고 분배하는 작업은 모두 쌓인 카드 목록인 데크에서 일어나는 일이므로 CDeck 클래스가 직접 처리할 수도 있다. 그러나 이 예제는 데크가 아닌 외부에서 초기화를 하도록 했는데 설계 편의성과 재활용성 확보를 위해 이 방법이 훨씬 더 유리하다. 왜냐하면 데크는 플레이어의 수나 분배 규칙에 대해서는 아는 정보가 없으며 오로지 쌓인 카드의 집합만 관리할 수 있다. 부품들끼리는 가급적이면 서로 독립적이어야 하며 서로의 존재를 알지 못해야 재사용성이 높아진다. 만약 Initialize 함수가 CDeck의 멤버 함수라면 이 예제의 클래스로는 오로지 2인용 고스톱밖에 못 만드는 제약이 생긴다.

DrawScreen 함수는 정말 쉬운 함수이다. Screen을 Draw한다. 화면을 지워 깨끗하게 만들고 각 객체들의 Draw 함수를 차례대로 호출하면 된다. 남군, 북군은 현재 차례의 플레이어에게 선택 번호를 출력하도록 하며 데크는 뒤집지 않은 상태로 그린다. OutPrompt 함수는 짧은 메시지를 화면에 출력하고 이 메시지를 읽을 동안 잠시 대기한다. 사용자에게 프로그램의 현재 상태를 출력하거나 다음 행동을 지시할 때 이 함수가 사용된다. 그래픽 환경이라면 아마 메시지 박스가 사용되었을 것이다.

InputInt 함수는 정수값 하나를 키보드로부터 입력받는다. 이 게임은 오로지 키보드를 통해서만 할 수 있으므로 입력 함수가 아주 자주 사용된다. 어떤 패를 낼 것인지, 먹을 카드가 여러 개일 때 어떤 카드를 먹을지, 점수를 획득했을 때 계속할 건지 아니면 스톱할 건지 등의 옵션을 모두 키보드로 입력받아야 한다. 이때 각 입력시마다 유효한 수의 범위가 정해지는데 가진 패가 다섯 장이면 1~5 중 하나만 골라야 한다. 그래서 허용된 범위 내에서 숫자만 입력받는 함수를 따로 만들어 두었다.

InputInt는 키 입력을 대기하고 입력받은 키를 점검하여 숫자가 아니면 무시하고 다시 입력받으며 범위를 벗어났을 때도 에러 메시지를 출력한 후 다시 입력받는다. 단, 특별히 A키만 인정하여 이 키가 눌러지면 정수 10을 리턴하는데 게임 초반에 10장의 카드 중 마지막 카드를 낼 수 있어야 하기 때문이다. 어쨌든 이 함수를 호출하면 지정한 범위내의 키 중 하나를 입력할 때까지는 절대로 리턴하지 않도록 되어 있으므로 main은 사용자 입력이 항상 정확하다는 것을 확신할 수 있으며 에러 처리를 할 필요가 없다.

객체 지향적인 프로그래밍 기법에서는 가급적이면 전역변수나 전역 함수는 사용하지 않도록 권장되며 모든 것을 클래스화하여 캡슐화해야 한다고 가르친다. 이 예제는 간편함을 위해 전역변수, 전역 함수를 적절하게 사용하고 있는데 꼭 전역변수를 없애고자 한다면 다음과 같은 클래스를 하나 정의하고 변수와 함수를 모두 이 안에 포함시키면 된다.

 

class Game

{

public:

     CDeck Deck(18,9);

     CPlayer South(5,20), North(5,1);

     CBlanket Blanket(5,12);

     CPlayerPae SouthPae(40,14), NorthPae(40,4);

     bool SouthTurn;

 

     void Initialize();

     void DrawScreen();

     void OutPrompt(const char *Mes,int Wait=0);

     int InputInt(const char *Mes, int range);

};

 

이렇게 선언해 놓고 main에서 Game 타입의 G를 선언한 후 G의 멤버를 참조하면 똑같은 예제를 만들 수 있다. 뭣하러 이런 짓을 해 가며 굳이 클래스로 포장하려고 애쓰느냐고 하겠지만 자바나 C#같은 완전한 객체 지향 언어들은 실제로 이런 방식을 사용하며 모든 것이 객체가 될 수 있다며 자랑한다. 그러나 C++은 혼합형 언어이므로 이렇게까지 할 필요는 없다. C++은 아무리 발버둥을 쳐도 main이 전역 함수이므로 완전한 객체 지향이 될 수 없으며 그럴 필요도 없는 것이다.

main 함수

그럼 이제 마지막 함수 main을 분석해 보자. main은 이 프로그램에 등장하는 모든 객체를 지휘하는 총사령관이며 게임을 운영하는 주체이다. 게임 규칙이 복잡하다 보니 소스의 길이도 긴 편인데 각 부분의 역할이 명확히 구분되며 시간순으로 순서대로 흘러가는 식이므로 분석하기는 그리 어렵지 않다. 좀 더 작은 함수들로 분할해 볼 수도 있겠지만 어차피 한 덩어리라 분할이 자연스럽지 못하고 분석하기에 더 번거로와지는 것 같아 관두기로 했다.

 

// 프로그램을 총지휘하는 main 함수

void main()

{

     int i,ch;

     int arSame[4],SameNum;

     char Mes[256];

     CPlayer *Turn;

     CPlayerPae *TurnPae,*OtherPae;

     int UserIdx,UserSel,DeckSel;

     SCard UserCard, DeckCard;

     bool UserTriple, DeckTriple;

     int nSnatch;

     int NewScore;

 

     randomize();

     Initialize();

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

          DrawScreen();

          if (SouthTurn) {

              Turn=&South;

              TurnPae=&SouthPae;

              OtherPae=&NorthPae;

          } else {

              Turn=&North;

              TurnPae=&NorthPae;

              OtherPae=&SouthPae;

          }

 

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

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

          if (ch == 0) {

              if (InputInt("정말 끝낼겁니까?(0:예,1:아니오)",0,1)==0)

                   return;

              else

                   continue;

          }

 

          // 플레이어가 카드를 한장 낸다.

          UserTriple=DeckTriple=false;

          UserIdx=ch-1;

          UserCard=Turn->GetCard(UserIdx);

          SameNum=Blanket.FindSameCard(UserCard,arSame);

          switch (SameNum) {

          case 0:

              UserSel=-1;

              Blanket.InsertCard(Turn->RemoveCard(UserIdx));

              DrawScreen();

              break;

          case 1:

              UserSel=arSame[0];

              break;

          case 2:

              if (Blanket.GetCard(arSame[0]) == Blanket.GetCard(arSame[1])) {

                   UserSel=arSame[0];

              } else {

                   Blanket.DrawSelNum(arSame);

                   sprintf(Mes,"어떤 카드를 선택하시겠습니까?(1~%d)",SameNum);

                   UserSel=arSame[InputInt(Mes,1,SameNum)-1];

              }

              break;

          case 3:

              UserSel=arSame[1];

              UserTriple=true;

              break;

          }

          if (UserSel != -1) {

              Blanket.DrawTempCard(UserSel,UserCard);

          }

          delay(Speed);

 

          // 데크에서 한장을 뒤집는다.

          Deck.Draw(true);

          delay(Speed);

          DeckCard=Deck.Pop();

          SameNum=Blanket.FindSameCard(DeckCard,arSame);

          switch (SameNum) {

          case 0:

              DeckSel=-1;

              break;

          case 1:

              DeckSel=arSame[0];

              if (DeckSel == UserSel) {

                    if (Deck.IsNotLast()) {

                        Blanket.InsertCard(DeckCard);

                        Blanket.InsertCard(Turn->RemoveCard(UserIdx));

                        OutPrompt("설사했습니다.",PromptSpeed);

                        continue;

                   } else {

                        DeckSel=-1;

                   }

              }

              break;

          case 2:

              if (UserSel == arSame[0]) {

                   DeckSel=arSame[1];

              } else if (UserSel == arSame[1]) {

                   DeckSel=arSame[0];

              } else {

                   if (Blanket.GetCard(arSame[0]) == Blanket.GetCard(arSame[1])) {

                        DeckSel=arSame[0];

                   } else {

                        Blanket.DrawSelNum(arSame);

                        sprintf(Mes,"어떤 카드를 선택하시겠습니까?(1~%d)",SameNum);

                        DeckSel=arSame[InputInt(Mes,1,SameNum)-1];

                   }

              }

              break;

          case 3:

              DeckSel=arSame[1];

              DeckTriple=true;

              break;

          }

          if (DeckSel != -1) {

              Blanket.DrawTempCard(DeckSel,DeckCard);

          }

          Deck.Draw(false);

          delay(Speed);

 

          // 일치하는 카드를 거둬 들인다. 세 장을 먹은 경우는 전부 가져 온다.

          if (UserSel != -1) {

              if (UserTriple) {

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

                        TurnPae->InsertCard(Blanket.RemoveCard(UserSel-1));

                   }

              } else {

                   TurnPae->InsertCard(Blanket.RemoveCard(UserSel));

              }

              TurnPae->InsertCard(Turn->RemoveCard(UserIdx));

              if (DeckSel != -1 && DeckSel > UserSel) {

                   DeckSel-=(UserTriple ? 3:1);

              }

          }

          if (DeckSel != -1) {

              if (DeckTriple) {

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

                        TurnPae->InsertCard(Blanket.RemoveCard(DeckSel-1));

                   }

              } else {

                   TurnPae->InsertCard(Blanket.RemoveCard(DeckSel));

              }

              TurnPae->InsertCard(DeckCard);

          } else {

              Blanket.InsertCard(DeckCard);

          }

 

          // 쪽, 따닥, 싹쓸이 조건을 점검하고 상대방의 피를 뺏는다.

          nSnatch=0;

          if (Deck.IsNotLast()) {

              if (UserSel == -1 && SameNum == 1 && DeckCard.GetNumber() == UserCard.GetNumber()) {

                   nSnatch++;

                   OutPrompt("쪽입니다.",PromptSpeed);

              }

              if (UserSel != -1 && SameNum == 2 && DeckCard.GetNumber() == UserCard.GetNumber()) {

                   nSnatch++;

                   OutPrompt("따닥입니다.",PromptSpeed);

              }

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

                   nSnatch++;

                   OutPrompt("싹쓸이입니다.",PromptSpeed);

              }

              if (UserTriple || DeckTriple) {

                   OutPrompt("한꺼번에 세 장을 먹었습니다.",PromptSpeed);

                   nSnatch += UserTriple + DeckTriple;

              }

          }

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

              TurnPae->InsertCard(OtherPae->RemovePee());

          }

 

          // 점수를 계산하고 고, 스톱 여부를 질문한다.

          NewScore=TurnPae->CalcScore();

          if (Deck.IsNotLast() && NewScore > TurnPae->OldScore) {

              DrawScreen();

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

                   TurnPae->OldScore=NewScore;

                   TurnPae->IncreaseGo();

              } else {

                   break;

              }

          }

     }

     DrawScreen();

     OutPrompt("게임이 끝났습니다.",0);

}

 

main의 선두에는 필요한 지역변수들이 선언되어 있고 난수 발생기를 초기화하며 Initialize를 호출하여 게임판을 초기화한다. 그리고 곧바로 for 루프로 진입하는데 이 루프가 전체 게임 루프이다. 최초 남군 차례부터 시작하며 한 번 루프를 돌 때마다 차례가 바뀐다. for 루프 한 번이 플레이어가 카드 하나를 낼 때를 처리한다고 생각하면 된다. 루프의 종료 조건은 데크가 비지 않을 때까지이므로 마지막 카드를 뒤집을 때까지 게임이 계속된다. 물론 게임 중간에 점수가 날 경우 플레이어가 게임을 끝낼 수도 있다.

루프 선두에서는 먼저 화면을 그려 각 객체의 현재 상태를 보인다. 그리고 본 처리에 들어가기 전에 약간의 준비 동작을 하는데 차례에 따라 플레이어와 먹은 패, 상대의 먹은 패 객체를 Turn, TurnPae, OtherPae 포인터로 미리 선택해 놓는다. 카드를 낼 때 플레이어의 패를 담요로 옮기고 담요의 일치하는 카드들은 먹은 패로 옮기며 상대방의 피를 가져오기도 해야 하는데 매번 누구 차례인지를 점검하기는 번거롭기 때문에 미리 선택해 놓는 것이다. 다음 코드 예를 보면 포인터로 미리 대상을 선택해 놓는 것이 얼마나 효율적인가를 알 수 있을 것이다.

 

if (SouthTurn) {

  South에서 카드 빼서 담요로

  일치하는 카드는 SouthPae로 이동

  쪽, 따닥시에 NorthPae의 피 한장 가져 옴

} else {

  Notth에서 카드 빼서 담요로

  일치하는 카드는 NorthPae로 이동

  쪽, 따닥시에 SouthPae의 피 한장 가져 옴

}

Turn에서 카드 빼서 담요로

일치하는 카드는 TurnPae로 이동

, 따닥시에 OtherPae의 피 한장 가져 옴

 

필요할 때마다 조작 대상을 선택하면 차례에 따라 똑같은 코드가 두 번 반복되어야 하지만 포인터로 미리 조작 대상을 선택해 놓고 포인터를 통해 대상을 조작하면 한벌의 코드만으로도 양쪽 차례를 모두 처리할 수 있다. 불필요하게 반복되는 코드는 무슨 수를 쓰더라도 통합해 놔야 관리가 쉬워진다. 이후 코드에서 Turn은 방금 카드를 낸 플레이어이며 TurnPae는 먹은 카드가 이동할 곳이라고 생각하면 된다.

대상을 선택한 후 어떤 카드를 낼 것인가를 질문한다. 플레이어가 선택할 수 있는 번호의 범위는 가지고 있는 카드의 개수만큼이다. 입력을 받은 후 프로그램 종료 처리를 하는데 0이 입력되면 루프를 탈출하되 실수로 0을 누를 수도 있으므로 한 번 더 확인하도록 했다. 플레이어가 카드를 한 장 내면 다음은 이 카드와 담요에 깔린 카드를 비교하여 게임을 진행한다.

세 장 한꺼번에 먹기를 기억하는 UserTriple, DeckTriple은 일단 아닌 것으로 초기화해 놓고 사용자가 낸 카드의 번호 UserIdx와 카드 자체인 UserCard를 구해 놓는다. 입력받는 값은 1이 시작(One Base)이지만 내부적으로 카드 번호는 0부터 시작(Zero Base)하므로 ch에 1을 빼야 올바른 카드의 번호가 된다. 담요에 깔린 카드와 플레이어가 낸 카드가 몇 개나 일치하는지를 FindSameCard 함수로 구하며 이 함수의 리턴값, 즉 일치하는 개수에 따라 다음 처리가 상당히 달라진다. 일치하는 개수는 0~3까지이며 4는 있을 수 없다. 개수별로 처리를 해 보되 사용자가 담요의 어떤 카드를 먹을 것인지가 UserSel변수에 선택된다.

 

① 하나도 일치하지 않는 경우

먹을 게 없어서 카드를 버린 것이다. UserSel은 아무 것도 먹지 못한다는 의미로 -1이 대입되고 플레이어가 낸 카드는 즉시 담요로 이동하며 화면을 다시 그려 버린 카드가 담요에 나타나야 한다. 바로 다음에 데크에서 뒤집은 카드가 버린 카드와 일치할 수도 있으므로 이 카드를 담요에 곧장 삽입해야 한다. 일단 버린 카드는 다시 가져올 수 없는데 이것이 바로 고스톱의 절대 원칙인 낙장 불입이다.

② 하나만 일치하는 경우

플레이어가 담요의 해당 카드를 먹을 생각으로 일치하는 카드를 낸 것이다. 더 고민할 필요없이 일치한 카드를 선택해 놓으면 된다. UserSel은 첫 번째 일치하는 카드인 arSame[0]값을 가진다. 그러나 아직 이 카드를 먹은 패로 이동해서는 안된다. 왜냐하면 데크를 뒤집어 똑같은 카드가 나오면 설사를 할 수도 있기 때문이다. 그래서 UserSel은 잠재적으로 플레이어가 먹은 것으로 취급되기는 하지만 아직 완전히 먹은 것은 아니다.

③ 두 개가 일치하는 경우

이 때는 조금 골치가 아프다. 둘 중 어떤 것을 선택할 지 플레이어에게 질문을 하고 선택한 카드를 UserSel에 대입한다. 그러나 일치하는 카드가 두 장이라고 해서 무조건 질문을 해서는 안되는데 두 카드(arSame[0]와 arSame[1])가 완전히 같다면 굳이 질문할 필요가 없다. 다음은 플레이어가 4오 카드를 냈을 때의 상황인데 양쪽 다 담요에 4번 카드가 두 장씩 있다.

왼쪽의 경우 4십, 4피가 각각 있는데 이 두 카드는 분명히 다른 카드이다. 그래서 어떤 카드를 취할지 질문을 해야 한다. 이 플레이어가 고도리를 노리고 있다면 4십 카드를 취할 것이요 피로 점수를 왕창 내려고 작정했다면 4피를 먹을 것이다. 그러나 오른쪽의 경우는 4번 카드가 두 장 있더라도 둘 다 피이므로 어떤 것을 먹으나 마찬가지이다. 이 경우는 질문할 필요가 없으며 불필요한 질문을 해서도 안된다. 실제 화투패에는 같은 4피라도 그림이 조금 다르게 그려져 있기는 하다.

 

하지만 어떤 카드를 가져 오나 점수에는 하등의 차이가 없으므로 플레이어는 그냥 첫 번째 카드(arSame[0])를 가져다 주면 만족해할 것이다. 고스톱판에서 같은 숫자의 피 두 장을 놓고 어떤 그림이 예쁜지 한참 고민하고 있다가는 돈 잃은 친구에게서 재떨이가 날라올 지도 모른다. 고스톱은 호흡이 아주 빠른 게임이라 선택이 느리면 재미가 반감된다.

질문을 할 때는 담요의 DrawSelNum을 호출하는데 일치하는 카드 목록인 arSame을 주면 이 목록에 있는 카드의 위쪽에 [1], [2] 숫자를 출력한다. 그리고 InputInt 함수로 1, 2 중 하나를 입력받아 arSame에서 플레이어가 선택한 카드를 UserSel에 대입한다. 입력은 1, 2 중 하나를 받지만 arSame의 일치하는 카드는 0, 1번에 들어 있으므로 플레이어가 누른 키에서 1을 뺀 첨자를 읽어야 한다. UserSel이 곧 플레이어가 먹고 싶어하는 카드이다.

④ 세 개가 일치하는 경우

담요에 처음부터 세 장이 깔린 것이나 상대편이 설사해 놓은 것을 먹은 경우이다. 한마디로 운수 대박인 셈인데 이때는 네 장의 카드를 몽땅 먹고 상대방의 피까지 하나 뺏어 올 수도 있다. 이 처리를 위해 UserTriple에 true를 대입해 놓는데 이는 플레이어가 낸 카드로 담요의 카드 세 장을 한꺼번에 먹었다는 표식이다. UserSel은 세 카드 중 가운데 카드 번호를 대입해 놓는다.

 

UserSel이 가운데 카드만 가리키고 있지만 UserTriple이 true로 되어 있으므로 나중에 카드를 가져 올 때 좌우의 카드까지 같이 가져오면 된다. 담요의 카드는 항상 정렬되어 있으므로 UserTriple이 true일 때 UserSel 양쪽은 항상 같은 번호의 카드이다.

 

일치하는 카드 개수에 따라 UserSel, UserTriple 변수에 어떤 카드를 먹어야 하는지를 잘 기록해 놓는다. 그리고 화면에는 플레이어가 낸 카드를 먹을 카드 아래쪽에 출력해 놓고 잠시 대기한다. 게임이 너무 급격하게 진행되면 사용자가 진행 상황을 잘 파악하지 못하므로 적절한 대기 시간이 필요하다.

다음은 데크에서 한장을 뒤집는다. 뒤집은 카드가 무엇인지 확인시켜 주기 위해 데크의 제일 위쪽 카드를 데크 오른쪽에 그려 표시하고 잠시 대기한다. CDeck.Draw로 true를 전달하면 ??? 옆에 제일 위쪽 카드를 잠시 표시하도록 되어 있다. 확인이 끝나면 Pop 함수로 데크의 제일 위쪽 카드를 DeckCard로 가져오는데 일단 뒤집은 카드를 데크에 다시 넣을 일은 없으므로 이 시점에서 데크의 위쪽 카드를 완전히 제거해도 상관없다.

뒤집은 카드가 담요의 카드와 어떻게 일치하는지, 몇 개나 일치하는지에 따라 다음 게임 진행이 결정된다. 이때도 일치하는 카드 개수에 따라 처리가 각각 다르다. 각 케이스에서 데크의 카드가 담요의 어떤 카드를 먹을지 DeckSel을 선택해야 한다. 일치하는 개수별로 처리해 보자. 플레이어가 카드를 냈을 때와 비슷한 상황이지만 플레이어가 낸 카드까지 고려해야 하므로 처리 과정이 훨씬 더 복잡하다.

 

① 하나도 일치하지 않는 경우

운이 따라 주지 않는 경우인데 DeckSel에 -1을 대입하여 데크에서 뒤집은 카드로 담요의 카드를 먹지 못함을 표시해 놓기만 한다. 플레이어의 카드는 다음 판단을 위해 즉시 담요로 보냈지만 데크의 카드는 바로 담요에 삽입하지 말고 플레이어 카드가 처리될 때까지 대기해야 한다. 만약 데크의 카드를 담요로 지금 보내면 앞서 조사해 놓은 UserSel이 무효가 될 수도 있다. 다음 경우를 보자.

플레이어가 9피를 내고 4번째 카드인 9십 카드를 찜해 놓은 상황에서 데크를 뒤집었는데 3광이 나왔다고 하자. 이 카드를 담요에 삽입하면 InsertCard가 정렬을 하는 과정에서 3광보다 뒤쪽 카드들이 한 칸씩 뒤로 밀려 버린다. 이렇게 되면 UserSel이 가리키고 있는 4번째 카드는 8광이 되어 버릴 것이며 8광과 9피가 먹은 패로 같이 삽입될 것이다. 번호가 다른 카드를 잘못 가져 갔으므로 게임을 끝까지 진행해도 담요에 남는 카드가 생기게 되며 이 상황을 이른바 나가리라고 한다.

물론 데크에서 뒤집은 카드가 UserSel 보다 뒤쪽에 삽입된다면 아무 문제가 없을 것이다. 위 그림에서 데크에서 뒤집은 카드가 J피라면 담요에 냉큼 삽입해도 상관없다. UserSel이 먹을 카드 자체를 가지고 있는 것이 아니라 담요에서의 첨자 번호를 가지고 있기 때문에 이 카드를 완전히 접수하기 전에는 첨자가 유효하도록 계속 보호해야 한다. 그래서 DeckSel에 -1만 기록해 놓고 데크에서 뒤집은 카드를 담요로 보내는 처리는 UserSel을 처리한 후로 보류한다.

② 하나만 일치하는 경우

일단 DeckSel에 일치한 카드의 첨자인 arSame[0]를 대입해 놓는다. 데크를 뒤집을 때는 플레이어가 카드를 낼 때 발생하지 않는 사건이 있는데 바로 설사 처리이다. 만약 사용자가 이미 먹으려고 찜해 놓은 카드가 데크에서 또 나왔다면 이를 설사라고 하는데 바로 다음 상황이다.

설사를 하면 플레이어가 낸 카드와 데크에서 뒤집은 카드를 모두 담요로 반납하고 자기 차례가 완전히 끝난다. 점수가 바뀌거나 게임이 끝나는 일도 없으므로 카드를 즉시 반납하고 루프 선두로 돌아가면 된다. 단, 막판인 경우에는 설사가 없으므로 이때는 UserSel만 이 카드를 가져가고 데크는 -1을 대입하여 못 먹은 것으로 취급한다. 여기서 DeckSel에 -1을 명시적으로 대입해 놓지 않으면 담요의 카드 하나를 플레이어와 데크의 카드가 서로 먹으려고 할 것이다. 한 번에 카드 세 장을 가져 오면 이것도 나가리가 되고 만다.

③ 두 개가 일치하는 경우

가장 복잡한 케이스인데 통상의 경우는 플레이어가 낸 카드의 경우와 같다. 즉, 담요의 두 카드가 같으면 질문없이 첫 번째 일치하는 것을 선택하고 틀릴 경우는 어떤 카드를 취할지 질문해야 한다. 데크의 카드는 여기에 한가지 조건이 더 들어가는데 두 장 중 하나를 플레이어가 이미 찜해 놓았다면 별도의 질문없이 남은 하나를 그냥 취하면 된다.

7번 카드가 두 장 깔려 있는데 플레이어가 7피를 내고 7십을 선택했다면 데크에서 나온 7번 카드는 나머지 7피를 선택할 수밖에 없다. 반대로 플레이어가 먼저 7피를 선택했다면 데크의 카드는 7십을 가져 가야 한다. 플레이어의 카드로 두 장을 먹고 뒤집은 카드로 같은 카드 두 장을 또 먹은 이 상황을 따닥이라고 하는데 이 조건 점검은 뒤에서 따로 하고 있다. 지금 이 단계에서는 따닥을 처리하지 않는데 왜냐하면 카드를 이동시킨 후에 메시지를 출력하는 것이 더 보기 좋기 때문이다.

④ 세 개가 일치하는 경우

이 경우는 다소 간단하다. DeckSel은 가운데 카드를 선택해 놓고 DeckTriple은 true로 바꿔 놓는다. 여기서 막판 점검은 하지 않는데 설사 막판이라 하더라도 일치한 카드는 다 먹어야 하기 때문이다. 막판인가 아닌가에 따라 달라지는 것은 상대방의 피를 가져올 것인가 아닌가이며 이 처리는 뒤에서 점수를 계산할 때 조건을 잘 점검하므로 걱정하지 않아도 된다.

 

여기까지 처리한 후 데크에서 뒤집은 카드를 DeckSel 자리에 표시하고 데크의 뒤집은 카드는 다시 숨긴다. 그리고 잠시 대기하여 무슨 일이 일어나고 있는지를 확인시킨다. 아직까지 카드를 먹은 것은 하나도 없고 UserSel, DeckSel에 앞으로 먹을 카드의 후보들만 표시되어 있다. 이제 실컷 조사해 놓은 카드를 냠냠 드시고 점수를 계산해 볼 차례다. UserSel이 -1이 아니라면 담요의 카드를 먹었다는 뜻이므로 일치한 두 카드를 먹은 패로 이동시키면 된다. 다음 두 줄로 두 개의 카드를 TurnPae에 삽입한다.

 

TurnPae->InsertCard(Blanket.RemoveCard(UserSel));

TurnPae->InsertCard(Turn->RemoveCard(UserIdx));

 

담요의 UserSel이 가리키는 카드를 제거하면서 TurnPae에 삽입하고 플레이어(Turn)의 카드 중 사용자가 선택한 카드를 제거하면서 TurnPae에 삽입하면 두 개의 카드가 먹은 패로 이동된다.

그러나 실제 코드는 이보다 조금 더 복잡한데 두 가지 상황을 더 처리해야 하기 때문이다. 우선 세 장을 한꺼번에 먹은 경우(UserTriple이 true)를 처리해야 한다. 이때는 담요에서 세 장을 다 가져와야 하는데 UserSel이 세 카드의 중앙을 가리키고 있으므로 UserSel 뿐만 아니라 UserSel+1, UserSel-1도 가져와야 한다. 세 카드를 각각의 첨자로 가져오는 대신 UserSel-1을 세 번 가져 오면 훨씬 더 간단하다.

이렇게 되는 이유는 담요의 카드가 정렬되어 있어 같은 숫자의 카드끼리 인접해 있으며 RemoveCard 함수가 하나를 제거하면서 뒤쪽에 있는 카드들을 한 칸씩 앞쪽으로 이동시키기 때문이다. 마치 세 글자를 지울 때 제일 앞에서 Del 키를 세 번 누르면 되는 것과 같은 이치이다. 첨자를 바꿔 가며 함수를 세 번 호출하는 것보다는 똑같은 코드를 세 번 반복하는 것이 더 쉽다.

UserSel을 제거할 때 뒤쪽에 있는 DeckSel도 같이 이동해야 한다. 두 변수가 같은 배열상의 첨자를 가리키고 있으므로 한 첨자가 제거되면 나머지 첨자도 영향을 받기 때문이다. 단, DeckSel이 UserSel보다 더 앞쪽에 있다면 영향을 받지 않는다. DeckSel이 -1이 아니고 UserSel보다 더 뒤쪽이면 제거된 개수만큼 DeckSel도 앞으로 이동해야 정확한 카드를 가리킬 수 있다.

데크를 뒤집어서 일치한 카드를 먹는 것은 플레이어가 낸 카드를 먹는 것과 거의 동일하다. 단, 한가지 더 추가되는 처리는 DeckSel이 -1일 때, 즉 일치하는 카드가 없어 먹을 게 없을 때 이 카드가 담요로 이동한다는 점이다. 플레이어의 카드는 일치하는 카드가 없을 때 즉시 담요로 반납되었지만 데크의 카드는 직전에 조사해 놓은 UserSel을 보호하기 위해 담요에 즉시 삽입하지 않았었는데 이 시점에서 카드를 담요에 삽입한다.

담요에 펼쳐진 카드를 다 거둬 들였으면 다음은 게임판의 여러 상황을 종합적으로 판단하여 조건이 맞을 경우 상대방의 피를 가져온다. 고스톱에는 이런 조건이 무려 다섯 가지나 되는데 모두 막판에는 인정되지 않는다는 공통점을 가지고 있다. 그래서 Deck.IsNotLast를 먼저 호출하여 데크의 남은 카드가 2장 이상인지를 먼저 점검했다. 뺏어올 카드의 숫자는 nSnatch 변수로 세는데 초기값은 물론 0이다.

쪽은 내가 낸 카드를 데크에서 뒤집어 내가 다시 먹은 경우이다. 조건문을 말로 풀어 보면 플레이어가 낸 카드는 아무 것도 먹지 못했고 데크에서 뒤집은 카드는 한 장을 먹었는데 그 카드가 좀 전에 낸 바로 그 카드라는 뜻이다. 이런 조건문을 만들 때는 화투패를 잘 펼쳐 놓고 어떤 경우를 우리가 쪽이라고 부르는지를 잘 관찰해 보고 그 결과를 코드로 옮기면 된다. 예를 들어 고스톱을 처음 배우는 친구가 "도대체 쪽이 뭐야?"라고 물었을 때 친구에게 설명하듯이 컴파일러에게 쪽의 조건을 코드로 기술하여 가르쳐 주는 것이다. 조건이 만족하면 메시지를 출력하고 뺏어올 카드 nSnatch를 1 증가시킨다.

나머지 코드들도 비슷한 방법으로 조건식을 만든다. 따닥은 플레이어의 카드와 일치하는 카드가 담요에 있고 데크에서 뒤집은 카드와는 두 장이 일치하여 양쪽 다 뭔가를 먹긴 먹었는데 두 카드의 숫자가 일치할 때이다. 글을 읽으면 굉장히 복잡해 보이지만 화투를 손에 들고 생각해 보면 쉽게 이해될 것이다. 싹쓸이의 조건은 제일 쉬운데 담요에 남은 카드가 하나도 없어야 한다. 한꺼번에 세 장을 먹는 경우는 앞에서 이미 조건 점검을 마쳤으므로 UserTriple, DeckTriple 변수만 살펴보면 된다. 두 경우 각각에 대해 피 한장씩을 뺏어 올 수 있다.

다섯 가지 조건을 모두 점검하면 뺏어올 피의 숫자가 nSnatch 변수에 대입되어 있을 것이다. 조건은 다섯 개이지만 nSnatch의 최대값은 3이다. 왜냐하면 쪽과 따닥, 한꺼번에 세 장 먹기는 동시에 만족될 수 없는 조건이기 때문이다. UserTriple, DeckTriple이 동시에 발생하면서 싹쓸이까지 했을 때 상대방의 피 세 장을 가져올 수 있는데 확률적으로 구경하기 무척 힘든 상황이다.

피까지 가져 왔으면 마지막으로 점수를 계산한다. TurnPae의 CalcScore 함수를 호출하면 현재 점수가 실시간으로 계산된다. 이 점수가 이전의 OldScore보다 더 커졌다면 고, 스톱 질문을 한다. 스톱하겠다면 그냥 게임을 끝내고 계속 하겠다면 OldScore를 갱신하고 고 회수를 1증가시킨 후 루프 선두로 돌아가면 된다. 단, 막판에는 어차피 끝나는 중이므로 이 질문을 할 필요가 없으며 무조건 스톱이다.

이상에서 설명한 과정이 데크의 카드를 다 뒤집거나 플레이어 중 하나가 스톱을 외칠 때까지 반복된다. for 루프의 바깥에는 마지막으로 화면을 한 번 그리고 게임이 끝났다는 메시지를 출력하는 코드만 있다.