게임 저장 구현 - 구조체를 파일로 저장하기
Project/Win32API로 던그리드 모작

게임 저장 구현 - 구조체를 파일로 저장하기

Woon2World :: Programming & Art Life

 

배경

게임은 게임 내용을 저장하고 불러오는 기능이 필요하다.

저장과 불러오기는 유저의 하드디스크에 게임 파일을 저장하고 불러오는 일로서, 파일 입출력에 해당한다.

 

문자열로 패턴화하여 .txt 나 .csv 와 같은 문서를 작성해 게임의 내용을 저장할 수도 있으나

훨씬 더 코드를 작성하기 편하면서 유저가 함부로 세이브파일을 뜯어볼 수 없고 파일 확장자명도 내 마음대로 지정 가능하도록 하기 위해 바이너리 파일로 저장한다.

 

 


아이디어

  • 저장할 정보들을 담는 구조체를 정의한다.
  • 파일 경로과 저장할 정보들을 담는 구조체를 참조해 저장/불러오기 를 구현한다.
    • 파일 경로에 해당하는 파일을 바이너리 모드로 연다.
    • 구조체의 내용을 구조체의 크기만큼 메모리에 저장/불러오기 한다.

 

 


SaveFile 구조체

// saveload.h
 
#ifndef _saveload
#define _saveload
 
#include <fstream>
#include "keyword.h"
#include <string>
 
class SaveLoad
{
public:
 
#pragma pack(push, 1)
    // 저장할 데이터 구조체
    // SaveData 객체를 프록시로 코드 내의 변수들과 상호작용하면 된다.
    struct SaveData
    {
        // 저장, 불러오기에 쓰일 변수들은 여기에서 추가하거나 제거한다.
        int a;
        int b;
        int c;
        short d;
    };
#pragma pack(pop)
 
    // ...
 
}
 
#endif
cs

 

#pragma pack 은 컴파일러가 구조체를 저장할 때 적용하는 구조체 패딩(structure padding)을 조작한다.

일반적으로 컴파일러는 cpu 레지스터 크기( void*의 크기 )를 고려하여 cpu의 동작이 효율적이도록 구조체의 크기를 조작한다.

cpu는 한 번에 데이터를 레지스터 크기만큼 읽어들이므로, 레지스터 크기 간격으로 메모리가 배치되어 있어야 쓸데없는 주소 이동 없이 구조체를 읽어들일 수 있기 때문이다.

 

  • 구조체 패딩이 없는 경우 이런 문제가 발생한다.
    만약 32비트 시스템에서 int, char, int, char, int 순으로 구조체 멤버가 나열되어 있다면
    • 1번째, int에 접근하면서 4바이트를 읽어들인다.
    • 2번째, char에 접근하면서 4바이트를 읽어들인다.
      • char의 크기는 1바이트이므로 3바이트만큼 되돌아간다.
    • 3번째, int에 접근하면서 4바이트를 읽어들인다.
    • 4번째, char에 접근하면서 4바이트를 읽어들인다.
      • char의 크기는 1바이트이므로 3바이트만큼 되돌아간다.
    • 5번째, int에 접근하면서 4바이트를 읽어들인다.

 

파일 저장/불러오기를 구현하면서는 멤버 변수를 하나하나 읽는 것이 아니라 구조체의 크기만큼 메모리를 통째로 읽어올 것이므로 구조체 패딩이 따로 필요가 없다. 따라서 위와 같은 조작을 통해 구조체 패딩을 1바이트 단위로 이루어지게 해 무효화한다.

 

 


SaveLoad 클래스 인터페이스

  • class SaveLoad
    설명 : 객체 생성이 불가능한, 저장/불러오기 함수 제공 목적의 클래스이다.

    • struct SaveData
      설명 : 저장/불러오기 로 입출력할 변수들을 모아놓은 구조체이다.
      예시
      • SaveData 구조체 정의
        struct SaveData { int hp, int level, int exp, int map_id, POINT pos };

      • SaveData 구조체 정의
        struct SaveData { int year, int month, int day, std::string weekday_name };

    • static void save_bin( const char* file_path, const SaveData& data )
      설명 : file_path 경로에 해당하는 파일을 열어 data 의 내용을 저장한다.
      예시
      • "MySaveFile.savedata" 파일 저장하기
        SaveLoad::save_bin( "MySaveFile.savedata", SaveLoad::SaveData{ /*...*/ } );

      • "저장.abcdefg" 파일 저장하기
        SaveLoad::SaveData data{ /*...*/ };  // 필요한 변수들로 초기화
        SaveLoad::save_bin( "저장.abcdefg", data );

    • static void load_bin( const char* file_path, SaveData& data )
      설명 : file_path 경로에 해당하는 파일을 열어 내용을 data 에 저장한다.
      예시
      • "SaveSlot1.savedata" 파일 불러오기
        SaveLoad::SaveData data;
        SaveLoad::load_bin( "SaveSlot1.savedata", data );

      • "저장.abcdefg" 파일 불러오기
        SaveLoad::SaveData sd;
        SaveLoad::load_bin( "저장.abcdefg", data );

 

SaveData 파일은 실제 프로그램 내 존재하는 변수들을 모아 전달하는 프록시 역할을 하므로, 저장할 시에 임시 객체로써 생성될 일 또한 예상된다.

SaveData를 참조( & )로만 받으면 이러한 임시 객체에 대해 save_bin 을 호출할 수 없다.

따라서 save_bin 은 const SaveData& 대신 우측값( r-value )으로 SaveData&& 를 받는 버전 오버로딩한다.

 

파일 경로도 다양한 문자열 타입이 들어올 수 있다.

const char*, const wchat_t*, const std::string&, const std::wstring& 네 가지의 경우에 대해 각각 오버로딩한다.

 

 


SaveLoad 클래스 구현

// saveload.h
 
#ifndef _saveload
#define _saveload
 
#include <fstream>
#include "keyword.h"
#include <string>
 
class SaveLoad NONCREATABLE
{
public:
 
#pragma pack(push, 1)
    // 저장할 데이터 구조체
    // SaveData 객체를 파일에 저장하고, (save_bin)
    // 파일을 SaveData 객체에 불러온다. (load_bin)
    // SaveData 객체를 프록시로 코드 내의 변수들과 상호작용하면 된다.
    struct SaveData
    {
        // 저장, 불러오기에 쓰일 변수들은 여기에서 추가하거나 제거한다.
        int a;
        int b;
        int c;
        short d;
    };
#pragma pack(pop)
 
    static void save_bin( const char* file_path, const SaveData& data )
    {
        save_bin_impl( file_path, data );
    }
 
    static void save_bin( const char* file_path, SaveData&& data )
    {
        save_bin_impl( file_path, std::move( data ) );
    }
 
    static void save_bin( const wchar_t* file_path, const SaveData& data )
    {
        save_bin_impl( file_path, data );
    }
 
    static void save_bin( const wchar_t* file_path, SaveData&& data )
    {
        save_bin_impl( file_path, std::move( data ) );
    }
 
    static void save_bin( const std::string& file_path, const SaveData& data )
    {
        save_bin_impl( file_path, data );
    }
 
    static void save_bin( const std::string& file_path, SaveData&& data )
    {
        save_bin_impl( file_path, std::move( data ) );
    }
 
    static void save_bin( const std::wstring& file_path, const SaveData& data )
    {
        save_bin_impl( file_path, data );
    }
 
    static void save_bin( const std::wstring& file_path, SaveData&& data )
    {
        save_bin_impl( file_path, std::move( data ) );
    }
 
    static void load_bin( const char* file_path, SaveData& data )
    {
        load_bin_impl( file_path, data );
    }
 
    static void load_bin( const wchar_t* file_path, SaveData& data )
    {
        load_bin_impl( file_path, data );
    }
 
    static void load_bin( const std::string& file_path, SaveData& data )
    {
        load_bin_impl( file_path, data );
    }
 
    static void load_bin( const std::wstring& file_path, SaveData& data )
    {
        load_bin_impl( file_path, data );
    }
 
    SaveLoad() = delete;
 
private:
    template < typename Str_t, typename SV >
    HELPER static void save_bin_impl( Str_t&& file_path, SV&& data )
    {
        std::ofstream out{ file_path, std::ios_base::binary | std::ios_base::out };
 
        if ( out.fail() )
            throw std::ios_base::failure{ "SaveLoad::save_bin_impl : cannot open file" };
 
        out.write( reinterpret_cast< char* >&data ), sizeof( decltype( data ) ) );
    }
 
    template < typename Str_t, typename SV >
    HELPER static void load_bin_impl( Str_t&& file_path, SV&& data )
    {
        std::ifstream in{ file_path, std::ios_base::binary | std::ios_base::in };
 
        if ( in.fail() )
            throw std::ios_base::failure{ "SaveLoad::load_bin_impl : cannot open file" };
 
        in.read( reinterpret_cast< char* >&data ), sizeof( decltype( data ) ) );
    }
};
 
#endif
cs

 

 

save_bin_impl, load_bin_impl 이 실제 구현을 가지고, save_bin, load_bin은 이 함수로 인자를 전달( forwarding ) 하는 역할밖에 하지 않는다.

save_bin_impl, load_bin_impl 은 어떤 종류의 문자열 타입도 받도록 하기 위해서 문자열을 템플릿 매개변수화, 보편참조( Scott Meyers, 2015, Effective Modern C++ )로 만들었고, 왼값과 오른값을 둘 다 받을 수 있도록 SaveFile도 템플릿 매개변수화, 보편참조로 만들었다.

 

굳이 사용자 코드에서 호출되는 함수들을 템플릿화 하지 않고 내부 템플릿 함수로의 포워딩을 선택한 이유가 있다.

템플릿 인자로 배열이 올 경우가 있는데, 배열은 그 크기까지 포함하여 하나의 타입이다.

템플릿은 다른 타입에 대해 다른 코드를 생성하여 인스턴스화된다.

문자열에 대해 템플릿 형식 연역이 일어날 때 const char[N] 과 같이 연역되는 경우 const char[0], const char[1], const char[2], const char[3], ... 은 모두 다른 타입이기 때문에 기하급수적으로 많은 템플릿 인스턴스가 만들어질 위험이 있다.

 

따라서 일단 문자열을 const char* 로 받은 후에 템플릿 함수에 전달하면 const char[N] 으로 연역될 일 없이 const char* 로 연역된다.

 

바이너리파일을 구조체로 읽고 쓸 때에는 std::fstreamread()write() 를 활용한다.

첫번째 인자로 받은 char* 에 두번째 인자로 받은 std::streamsize 만큼의 char를 읽는다/쓴다.

 

char1 byte 자료형, 즉 메모리의 한 바이트를 나타낼 수 있는 자료형이다.

따라서 이 함수들에 구조체의 주소를 첫번째 인자로 주고, 구조체의 크기를 두번째 인자로 주면 메모리에서 구조체의 크기( 바이트 수 )만큼을 읽어올 수 있다.

 

구조체의 주소는 그 구조체의 포인터 타입으로 char* 를 받는 함수에 넘길 수 없으므로, 포인터를 재해석할 수 있는 캐스팅인 reinterpret_cast 를 활용하여 char* 로 캐스팅해 넘긴다.

이 포인터로 별다른 작업을 할 것이 아니라 단지 입출력을 위한 주소로만 쓸 것이므로 char* 로 캐스팅해도 상관없다.

주소를 나타내는 것은 어떤 타입의 포인터로도 충분히 가능하기 때문이다.

 

 


테스트

더보기

 

// database.h
 
// ...
 
class SaveLoad NONCREATABLE
{
public:
 
    // ...
 
    struct SaveData
    {
        int a, b, c, d;
    };
 
    // ...
 
};
 
#include <iostream>
void test_save_file()
{
    SaveLoad::save_bin( "세이브 파일.savedata", SaveLoad::SaveData{ 10203040 } );
 
    SaveLoad::SaveData data;
    SaveLoad::load_bin( "세이브 파일.savedata", data );
 
    std::cout << data.a << ", " << data.b << ", " << data.c << ", " << data.d << std::endl;
    // output : 10, 20, 30, 40
}
cs

 

// database.h
 
// ...
 
struct someclass
{
    char n[ 20 ];
    int a;
    int b;
 
    friend std::ostream& operator<<std::ostream& os, const someclass& my )
    {
        return os << my.n << ", " << my.a << ", " << my.b;
    }
};
 
class SaveLoad NONCREATABLE
{
public:
 
    // ...
 
    struct SaveData
    {
        someclass a;        
        int b;
        someclass c;
        int d;
    };
 
    // ...

};
 
#include <iostream>
void test_save_file()
{
    SaveLoad::save_bin( "세이브 파일.savedata",
        SaveLoad::SaveData{ { "이순신"15451598 }, 1, { "삼도 수군 통제사"15931597 }, 2 } );
 
    SaveLoad::SaveData data;
    SaveLoad::load_bin( "세이브 파일.savedata", data );
 
    someclass a = data.a;
    int b = data.b;
    someclass c = data.c;
    int d = data.d;
 
    std::cout << a << ", " << b << ", " << c << ", " << d << std::endl;
    // output : 이순신, 1545, 1598, 삼도 수군 통제사, 1593, 1597, 2
}
cs

 

 

 

  • 10, 20, 30, 40 을 저장하고 불러오기

 

  • { "이순신", 1545, 1598 }, 1, { "삼도 수군 통제사", 1593, 1597 }, 2 를 저장하고 불러오기