2015년 2월 16일 월요일

[일기] SDL 틱탁톡

출처 : http://www.sdltutorials.com/sdl-tutorial-tic-tac-toe

 지금까지 게임 개발을 위한 기초를 세웠다. 지금까지 공통된 루틴을 다룰 기본 구조를 구축했고, 이벤트를 다루기 위한 구체적인 클래스를 만들었고 또한 어느정도 표면을 다루기 위한 클래스도 만들었다고 한다. 본 장에서는 이들을 모두 합할 것이다. 즉, 모두 이용하여 빌게*츠가 만들었다던 틱택톡 게임을 작성하는 것을 목적으로 한다. 걱정 말길 바란다. 간단하게 만들어낼 수 있다. 마지막 작업물을 기초로 작업을 계속해 나가자.

 첫째로 필요한 작업은 게임에 대한 계획을 세우는 것이다. 경험을 통해 우리는 틱택톡 게임이 O 또는 X를 그릴 수 있는 3x3 크기의 격자를 갖고 있다는 것을 알고 있을 것이다. 즉, 우리는 3 개의 그래픽 요소가 필요한데, 격자와, X 그리고 O 표시를 위한 요소들이다. 우리는 여러 개의 X, Y 요소가 필요없는데 컴퓨터로 원하는 만큼 많이 그릴 것이기 때문이다. 첫 작업을 끝내도록 하자. 격자는 600x600으로 준비하고 따라서 그 크기의 1/3인 200x200 픽셀 크기로 X와 O를 준비한다.

>http://www.sdltutorials.com/Data/Posts/103/grid.png
>http://www.sdltutorials.com/Data/Posts/103/x.png
>http://www.sdltutorials.com/Data/Posts/103/o.png

이미지는 준비되었다. 이제 프로그램으로 이들을 적재해 보자. CApp.h를 열어서 다음과 같이 변경하자. Test Surface를 제거하고 3 개의 표면(surface)를 추가한다.

#ifndef _CAPP_H_
    #define _CAPP_H_

#include <SDL.h>

#include "CEvent.h"
#include "CSurface.h"

class CApp : public CEvent {
    private:
        bool            Running;

        SDL_Surface*    Surf_Display;

    private:
        SDL_Surface*    Surf_Grid;

        SDL_Surface*    Surf_X;
        SDL_Surface*    Surf_O;

    public:
        CApp();

        int OnExecute();

    public:
        bool OnInit();

        void OnEvent(SDL_Event* Event);

                void OnExit();

        void OnLoop();

        void OnRender();

        void OnCleanup();
};

#endif

그리고 CApp.cpp를 열어서 역시 편집한다. Test Surface를 제거하고, 3 개의 표면을 추가한다.

#include "CApp.h"

CApp::CApp() {
    Surf_Grid = NULL;
    Surf_X = NULL;
    Surf_O = NULL;

    Surf_Display = NULL;

    Running = true;
}

int CApp::OnExecute() {
    if(OnInit() == false) {
        return -1;
    }

    SDL_Event Event;

    while(Running) {
        while(SDL_PollEvent(&Event)) {
            OnEvent(&Event);
        }

        OnLoop();
        OnRender();
    }

    OnCleanup();

    return 0;
}

int main(int argc, char* argv[]) {
    CApp theApp;

    return theApp.OnExecute();
}


 CApp_OnCleanup.cpp 에도 변경이 필요하다는 사실을 잊지 말자. 전에 했던 것과 같이, Test Surface를 없애고 세 줄을 다음과 같이 추가한다.

#include "CApp.h"

void CApp::OnCleanup() {
    SDL_FreeSurface(Surf_Grid);
    SDL_FreeSurface(Surf_X);
    SDL_FreeSurface(Surf_O);
    SDL_FreeSurface(Surf_Display);
    SDL_Quit();
}

이제 3개의 이미지를 위한 세 개의 표면이 준비되었다. 메모리에 적재를 해야 하므로 CApp_OnInit.cpp를 열어서 수정을 좀 해야할 것 같다. 이전의 test surface는 역시 제거하도록 하자. 그리고 세 표면을 메모리에 적재하는 작업을 추가한다. 이미지 파일명을 정확하게 기술하도록 하자. 또한 600x600 크기로 윈도우 창의 크기를 변경하자. 무슨 말인고 하니, 격자 이미지 크기가 600x600이므로 창의 크기도 적합하게 맞추어 빈 공간(여백)이 생기지 않게 하려 함이다.

#include "CApp.h"

bool CApp::OnInit() {
    if(SDL_Init(SDL_INIT_EVERYTHING) < 0) {
        return false;
    }

    if((Surf_Display = SDL_SetVideoMode(600, 600, 32, SDL_HWSURFACE | SDL_DOUBLEBUF)) == NULL) {
        return false;
    }

    if((Surf_Grid = CSurface::OnLoad("./gfx/grid.bmp")) == NULL) {
    return false;
    }

    if((Surf_X = CSurface::OnLoad("./gfx/x.bmp")) == NULL) {
    return false;
    }

    if((Surf_O = CSurface::OnLoad("./gfx/o.bmp")) == NULL) {
    return false;
    }

    return true;
}

파일명이 바뀌었다. 여기 예에서는 ./gfx/fksms 폴더를 만들어 여기에 이미지들을 다 집어넣었다. 게임 커질수록 폴더로 정리해 나가면 편리해 진다고 한다. 그렇기에, 여기서부터는 모든 이미지는 gfx 폴더에 넣도록 하겠다. 화면에 격자를 이제 표현해보자. CApp_OnRender.cpp를 열고 test surface를 격자를 그리도록 변경해 보자.

#include "CApp.h"

void CApp::OnRender() {
    CSurface::OnDraw(Surf_Display, Surf_Grid, 0, 0);

    SDL_Flip(Surf_Display);
}

컴파일을 시도하여 성공한다면 화면에 격자가 나타날 것이다. 여기에는 표면을 사용하기 위한 5 단계가 구성된다.

 > 표면을 선언하고
 > 널로 초기화하며
 > 메모리에 적재후
 > 화면에 그려낸후
 > 그것을 해제한다

이 5 단계를 연습하는 것은 좋은 경험이 된다. 나중에 이 중 적어도 한 개 과정을 깜박하게 되면 문제가 발생한다. 즉, 익숙해져야 발생가능한 문제를 예방할 수 있다. 가령 2단계를 놓치면 정의되지 않은 동작이 발생하며 자원 해제를 잊는다면 메모리 누수가 발생한다.

 여기서 사용하는 이미지에 뭔가 이상한 점이 발견된다. X와 O를 나타내는 이미지는 분홍색의 배경을 포함한다. 여기는 이유가 있는데, 이 색이 투명성을 구현하는데 기반이 될 것이기 때문이다. 즉, 분홍색이 쓰인 부분이, 투명해진다. 분홍색을 투명하게 할 것이다. SDL은 이를 하는 간단한 함수 SDL_SetColorKey를 제공한다. 이를 적용하기 위해, CSurface.h를 열어서 새 함수를 추가해보자.

#ifndef _CSURFACE_H_
    #define _CSURFACE_H_

#include <SDL.h>

class CSurface {
    public:
        CSurface();

    public:
        static SDL_Surface* OnLoad(char* File);

        static bool OnDraw(SDL_Surface* Surf_Dest, SDL_Surface* Surf_Src, int X, int Y);

        static bool OnDraw(SDL_Surface* Surf_Dest, SDL_Surface* Surf_Src, int X, int Y, int X2, int Y2, int W, int H);

        static bool Transparent(SDL_Surface* Surf_Dest, int R, int G, int B);
};

#endif

 이제 함수를 구현하자. CSurface.cpp 를 만들고 기능을 추가하라.

bool CSurface::Transparent(SDL_Surface* Surf_Dest, int R, int G, int B) {
    if(Surf_Dest == NULL) {
        return false;
    }

    SDL_SetColorKey(Surf_Dest, SDL_SRCCOLORKEY | SDL_RLEACCEL, SDL_MapRGB(Surf_Dest->format, R, G, B));

    return true;
}

3개의 추가 인수들에 주목하자. 세 개의 색깔을 나타내기 위하여 쓰인다. 즉 투명색이 될 색상을 지정한다. 고로, 사실상 투명을 적용할 색이 분홍색이어야 할 필요는 없다. 예로 적색을 투명색으로 만들고자 한다면, 세 개의 인수를 255, 0, 0으로 만들기만 하면 된다.

 이 함수는 첫째로 전달된 첫번째 표면 매개변수가 유효한지를 검사한다. 만일 그러하다면 투명색을 위한 색상을 지정한다. SDL_SetColorKey에는 투명색으로 변경하고자 하는 색상이 있는 표면(surface)을 첫째 인수로 넣으면 된다. 둘째 인수부터는 몇몇의 플래그 변수가 들어가는데, SDL이 연산을 어떻게 처리할지를 지시하는데 쓰인다. 셋째 인수는 투명색으로 만들 색생을 입력한다. 위에 적용된 플래그들은 기본적인 사항으로 첫째 플래그는 색상 키(color key)를 인자로 전달된 표면에 적용하도록 하고 둘째 플래그는 이를 적용할 때 RLE 가속을 사용하도록 지시하는 역할을 한다(이는 기본적으로 나중에 더 고속으로 그림을 그리도록 시도한다). 셋째 인수는 다소 복잡하다. 여기에 SDL_MapRGB가 색상을 만들기 위해 사용되었다. SDL_MapRGB 는 표면과 RGB 값을 인자로 취한다. 그리고 전달받은 표면에 가장 유사한 포맷으로 색상을 근사시킨다. 이게 왜 유용한가 하면, 모든 표면은 같은 색상표를 갖지 않기 때문이다. 옛 사용가능한 색상이 적었던 NES시대를 기억하면 된다. 같은 아이디어를 여기에도 적용하는데, SDL_MapRGB는 색상(color)을 취하여 표면 팔레트에 있는 색상과 가장 가까운 색상으로 매치시킨다.

 아직 이해가 잘 안가지만 새 함수에 우리의 표면들을 적용해보자. CApp_OnInit.cpp를 열어서 다음과 같이 변경한다.

#include "CApp.h"

bool CApp::OnInit() {
    if(SDL_Init(SDL_INIT_EVERYTHING) < 0) {
        return false;
    }

    if((Surf_Display = SDL_SetVideoMode(600, 600, 32, SDL_HWSURFACE | SDL_DOUBLEBUF)) == NULL) {
        return false;
    }

    if((Surf_Grid = CSurface::OnLoad("./gfx/grid.bmp")) == NULL) {
        return false;
    }

    if((Surf_X = CSurface::OnLoad("./gfx/x.bmp")) == NULL) {
        return false;
    }

    if((Surf_O = CSurface::OnLoad("./gfx/o.bmp")) == NULL) {
        return false;
    }

    CSurface::Transparent(Surf_X, 255, 0, 255);
    CSurface::Transparent(Surf_O, 255, 0, 255);

    return true;
}

 표면(그릴 이미지를 가진)들을 위한 모든 것들이 준비되었다. 다음 순서는 X와 O를 그리는 방법을 파악하는 것이다. 여기서 이들은 격자 내의 임의 위치에나 막 그려져서는 안된다. 같은 곳에 그려져서도 안된다. 따라서 9칸의 배열을 만든다. 이 배열 내의 값들은 격자의 각 셀의 상태를 나타낸다. 고로 0은 좌상단 칸을 나타내고 1은 중앙상측을 나타내고 2는 우상단, 3은 중앙좌측 순으로 나타나게 된다. CApp.h에 추가한다.

#ifndef _CAPP_H_
    #define _CAPP_H_

#include <SDL.h>

#include "CEvent.h"
#include "CSurface.h"

class CApp : public CEvent {
    private:
        bool            Running;

        SDL_Surface*    Surf_Display;

    private:
        SDL_Surface*    Surf_Grid;

        SDL_Surface*    Surf_X;
        SDL_Surface*    Surf_O;

    private:
        int        Grid[9];

    public:
        CApp();

        int OnExecute();

    public:
        bool OnInit();

        void OnEvent(SDL_Event* Event);

            void OnExit();

        void OnLoop();

        void OnRender();

        void OnCleanup();
};

#endif

 각 칸은 3개의 값을 가질 수 있다고 가정한다. 3이라는 숫자는 공백, X, O를 각각 나타내기 위해 정해지었다. 세개니까. 구체적으로 말해 각각을 0, 1, 2로 생각하기로 하자. 그런데 숫자는 읽기 어려우니 enum을 사용할 것이다. 다음과 같이 CApp.h에 추가해보자. 단, 위치는 Grid 배열 바로 밑이다.

enum {
    GRID_TYPE_NONE = 0,
    GRID_TYPE_X,
    GRID_TYPE_O
};

 여기까지 언급하던 파일들의 모든 코드 내용을 실었었다. 그러나 이제부터는 스스로 코드의 위치를 파악하기를 기대한다. 이는 사실 이전까지 코드의 일부만을 변경하든 전체를 변경하든 중간에 코드를 삽입하는 경우에 그 위치를 알리기 위해 코드 전체를 올려야만 하는 경우가 생겼었다. 다시 말해 이제는 부분 코드만 올리고 어디에 기재해야 할지를 알리는 방식으로 바꾸려 한다. 물론 필요하다면 전체 코드를 올릴 것이다.

 이제 각 격자의 셀이 어떤 상태인지를 알 수 있게 되었으므로 이제 해야할 것은 게임판(격자)을 초기화하는 것이다. CApp.h 파일 내부 바닥에 다음을 추가하자.

public:
    void Reset();

CApp.cpp를 열고 아래 코드를 메인 함수 바로 직전에(위에) 끼워 넣는다.

void CApp::Reset() {
    for(int i = 0;i < 9;i++) {
        Grid[i] = GRID_TYPE_NONE;
    }
}

이 반복문은 격자의 모든 셀을 GRID_TYPE_NONE으로 설정한다. 즉 모든 셀은 비어있다고 선언하는 것이다. 프로그램이 시작된 극초반에 이렇게 작업을 하기 위해, CApp_OnInit.cpp파일에 함수 호출문을 만든다.

//...

CSurface::Transparent(Surf_X, 255, 0, 255);
CSurface::Transparent(Surf_O, 255, 0, 255);

Reset();

지금까진 좋다. 다음에 필히 해야할 것은 화면에 X와 O를 배치하는 능력을 겸비하는 것이다. 새 함수를 만들자. 이 함수는 그 능력을 기능으로 갖는다. CApp.h를 다시 열어서 Reset 함수 바로 밑에 추가하자.

void SetCell(int ID, int Type);

후, 다시 CApp.cpp 열자. 그리고 이거 추가해.

void CApp::SetCell(int ID, int Type) {
    if(ID < 0 || ID >= 9) return;
    if(Type < 0 || Type > GRID_TYPE_O) return;

    Grid[ID] = Type;
}

이 함수는 인자 두 개를 가져가는데, 변화시킬 셀 번호를 첫 인자로, 변화할 상태를 두 번째로 넘기면 된다. 여기에 조건을 두어야 하는데, 제약 1은 배열의 경계를 벗어나서는 안된다는 것이다(그렇게 되면 프로그램은 박살난다). 그리고 제약 2는 우리가 두 번째로 전달한 타입이 적합한 것이어야 한다는 것이다. 무척 단순하지만 이것만 지켜도 훌륭하다. X, O를 그리는 기능을 구현하자. CApp_OnRender를 열면 된다. 격자(grid) 다음에 밑의 코드를 추가하자.

for(int i = 0;i < 9;i++) {
    int X = (i % 3) * 200;
    int Y = (i / 3) * 200;

    if(Grid[i] == GRID_TYPE_X) {
        CSurface::OnDraw(Surf_Display, Surf_X, X, Y);
    }else
    if(Grid[i] == GRID_TYPE_O) {
        CSurface::OnDraw(Surf_Display, Surf_O, X, Y);
    }
}

아 이건 뭔가 좀 지금까지 해온 것보다 복잡한 듯하다. 첫째로, 격자의 각 셀을 반복하여 접근한다. 둘째로, grid ID를 X축 좌표나 Y축 좌표로 변환하는 단계를 거친다. 이것은 2개의 구분된 방법으로 처리한다. X좌표는 i 변수의 나머지가 3인 것을 취하면 얻어지며, 3으로 나눈 몫을 취하면 Y좌표가 얻어진다. 여기에 각 셀의 크기인 200을 곱하여 좌표를 얻어낸다. 이는 자주 사용되므로 이해를 충분히 하길 장려한다.

 다음으로 할 일은 셀의 타입을 확인하고 그 셀을 표면에 적합한 위치에 그려내는 것이다.

그려야 할 표면을 판단하고 적용하기 위해 사용자와 컴퓨터와 의사소통할 방법이 필요하다. 이를 우리는 마우스 이벤트를 통해서 적용할 것인데, 사용자가 셀을 클릭한다면 그 셀을 적절하게 그릴 것이다. 이제 이를 처리하기 위한 CEvent 함수 중 하나를 오버로딩할 필요가 있다. CApp.h를 열어서 다음 함수를 OnEvent 바로 밑에 추가하되, OnExit 위에 둔다.

void OnLButtonDown(int mX, int mY);

CApp_OnEvent.cpp를 열고 아래를 적어낸다.

void CApp::OnLButtonDown(int mX, int mY) {
    int ID    = mX / 200;
    ID = ID + ((mY / 200) * 3);

    if(Grid[ID] != GRID_TYPE_NONE) {
        return;
    }

    if(CurrentPlayer == 0) {
        SetCell(ID, GRID_TYPE_X);
        CurrentPlayer = 1;
    }else{
        SetCell(ID, GRID_TYPE_O);
        CurrentPlayer = 0;
    }
}

첫째로, 여기서는 우리가 ID를 축 좌표로 변환했던 과정의 역과정을 처리한다. 즉 좌표로부터 ID를 얻어낸다. 그리고 해당 셀이 아직 공백인지 확인하는데, 공백이 아니라 X나 O로 채워져 있다면 바로 함수를 빠져나온다. 공백이라면 어느 플레이어의 차례인지를 확인해서 그 셀을 적절히 채운다(X 또는 O를). CApp.h를 열고 grid 배열 밑에다가 변수를 추가한다.

int CurrentPlayer;

또한 CApp.cpp에 이 변수를 기본값으로 초기화시킨다.

CApp::CApp() {
    CurrentPlayer = 0;

    Surf_Grid = NULL;
    Surf_X = NULL;
    Surf_O = NULL;

    Surf_Display = NULL;

    Running = true;
}

프로그램을 컴파일하면 어느정도 잘 동작하는 버전의 틱탁톡 프로그램이 보일 것이다. 축하한다.

이제 나머지는 스스로 작성해야 한다. 여기는 게임의 기초 틀을 만들었을 뿐이다. 그리고 대부분이 완성되었다. 더 나아가 X가 이겼는지, O가 이겼는지 아니면 비겼는지를 필요하다면 추가적인 이미지까지 동원하여 구성해보길 바란다. 자신이 있다면 AI까지 작성하여 플레이어를 상대하는 컴퓨터를 제작해 보길 바란다.

댓글 없음:

댓글 쓰기