UDN
Search public documentation:

DevelopmentKitGemsSaveGameStatesKR
English Translation
日本語訳
中国翻译

Interested in the Unreal Engine?
Visit the Unreal Technology site.

Looking for jobs and company info?
Check out the Epic games site.

Questions about support via UDN?
Contact the UDN Staff

UE3 홈 > UDK 젬 > 게임 상태 저장 (SaveGameState)
UE3 홈 > 인풋 / 아웃풋 > 게임 상태 저장 (SaveGameState)

게임 상태 저장 (SaveGameState)


문서 변경내역: James Tan 작성. 홍성진 번역.
UDK 2012년 3월 버전으로 최종 테스팅, PC/MAC/iOS 호환

개요


언리얼 엔진 1 과 2 는 단순히 메모리에 있는 전체 레벨을 디스크에 저장하는 식의 게임 저장을 지원했었습니다. 이 방법은 프로덕션 상태에서는 잘 돌아갔지만, 아쉽게도 나중에 콘텐츠 개발자가 콘텐츠를 변경해도 반영되지 않았습니다. 이 문제 해결을 위해 충분한 데이터를 저장하는 방식, 즉 먼저 레벨을 로드한 다음 레벨에 있는 모든 액터와 오브젝트에 저장된 게임 상태 데이터를 적용하는 식으로 저장된 게임을 나타냈습니다.

게임 상태 저장을 염두에 두고 개발하기


저장된 게임 상태가 돌아가는 방식 때문에, 월드에서 무언가를 소멸(destroy)시킬 때는 매우 조심해야 합니다. 액터를 소멸시키면 사라져 버리기 때문에 저장된 게임 상태 Serializer 가 선택할 수 없게 됩니다. 액터가 휘발성(transient)이라면 보통 문제가 되지 않습니다. 그러나 레벨 디자이너가 놓은 액터의 경우 레벨을 다시 로드하고 저장된 게임 상태를 적용할 때, 그에 대한 데이터가 없기 때문에 레벨 디자이너가 놓은 액터는 영향을 받지 못합니다!

게임 상태 저장 작동방식의 일반적인 흐름


플레이어가 레벨을 잠깐 플레이한 후 게임을 저장

예를 들기 위해서 이 UDK 젬에는 콘솔 명령 하나만 추가했습니다. 분명 게임에는 그래픽 유저 인터페이스가 붙을 테지만, 어쨌든 파일명 파라미터를 붙여 같은 콘솔 명령을 호출하는 것은 언제든지 할 수 있습니다. 아니면 콘솔 명령 함수는 특정 액터나 오브젝트의 인스턴스 안에서 실행한다고 달라지지 않기 때문에, 정적(static)으로 만들 수도 있습니다 (PlayerController::ClientMessage() 를 호출하지만, Actor::GetALocalPlayerController() 를 사용하면 언제나 로컬 플레이어 콘트롤러를 구할 수 있습니다).

콘솔 명령이 실행될 때, 게임 상태 저장 프로세스를 시작시킵니다. 먼저 SaveGameState 오브젝트가 인스턴싱됩니다. SaveGameState 오브젝트는 액터, 키즈멧, 마티네의 반복처리(iterate)와 직렬화(serialize)를 담당합니다. 그런 다음 파일명을 "세척(scrub)"합니다. 파일명 세척은 그저 잘못된 캐릭터가 추가되지는 않았는지 확인하는 것이지만, 이경우엔 공백만 검사했습니다. 좀 더 확실한 세척을 위해서라면 파일명에 \, /, ?, ! 같은 글자가 들어가지 않도록 확인하는 것이 좋을 것입니다. 세척 함수는 확장자가 붙어있지 않은 경우 "sav" 를 추가해 주는 역할도 합니다. 그런 다음 SaveGameState 는 액터, 키즈멧, 마티네에 대한 반복처리와 직렬화 처리를 요청받습니다. 마지막으로 BasicSaveObject() 를 통해 SaveGameState 가 디스크에 잘 저장된 경우, 플레이어에게 게임이 저장되었다 알리는 메시지가 전송됩니다.

SaveGameStatePlayerController.uc
  /**
   * 게임 상태를 지정된 파일명에 저장하는 실행 함수입니다.
   *
   * @param      FileName      SaveGameState 를 저장할 파일명
   */
  exec function SaveGameState(string FileName)
  {
    local SaveGameState SaveGameState;
  
    // SaveGameState 인스턴싱
    SaveGameState = new () class'SaveGameState';
    if (SaveGameState == None)
    {
      return;
    }
  
    // 파일명 세척
    FileName = ScrubFileName(FileName);
  
    // SaveGameState 더러 게임을 저장하라 요청
    SaveGameState.SaveGameState();
  
    // 디스크 상(으로)의 SaveGameState 오브젝트를 Serialize
    if (class'Engine'.static.BasicSaveObject(SaveGameState, FileName, true, class'SaveGameState'.const.SAVEGAMESTATE_REVISION))
    {
      // 성공했으면 메시지 전송
      ClientMessage("Saved game state to "$FileName$".", 'System');
    }
  }
  

레벨 이름 Serialize

저장된 게임 상태는 자신이 로드될 때 로드시킬 맵 이름이 무엇인지 알 수 있도록 하기 위해 레벨 이름(이나 맵 파일명)을 serialize 합니다. 이것을 환경설정 파일같은 다른 곳에 저장하는 것 보다는, 그냥 저장된 게임 상태 안에 보관하는 것이 이치에 맞습니다. 저장된 게임 상태는 저장할 필요가 있을 때만 변수를 설정해 주면 됩니다. 실제로 디스크에 저장되는 것은 BasicSaveObject() 가 해 주기 때문입니다. 스트리밍 레벨 중 보이는 것이 있거나 대기중인 로드 요청이 있는 경우, 배열에 저장하여 저장된 게임 상태가 다시 로드되면 스트리밍 레벨이 바로 로드될 수 있도록 합니다. 이 단계는 현재 GameInfo 클래스도 저장합니다.

SaveGameState.uc
  /**
   * SaveGameStateInterface, Kismet, Matinee 를 구현하는 모든 액터를 serialize 하여 게임 상태 저장.
   */
  function SaveGameState()
  {
    local WorldInfo WorldInfo;
  
    // 월드 인포를 구하고, 찾지 못하면 중단.
    WorldInfo = class'WorldInfo'.static.GetWorldInfo();
    if (WorldInfo == None)
    {
      return;
    }
  
    // 맵 파일명 저장.
    PersistentMapFileName= String(WorldInfo.GetPackageName());
  
    // 현재 스트림 인된 맵 파일명 저장.
    if (WorldInfo.StreamingLevels.Length > 0)
    {
      // 스트리밍 레벨을 대상으로 반복처리.
      for (i = 0; i < WorldInfo.StreamingLevels.Length; ++i)
      {
        // 보이거나 대기중인 로드 요청이 있는 레벨은 스트리밍 레벨 목록에 포함.
        if (WorldInfo.StreamingLevels[i] != None && (WorldInfo.StreamingLevels[i].bIsVisible || WorldInfo.StreamingLevels[i].bHasLoadRequestPending))
        {
          StreamingMapFileNames.AddItem(String(WorldInfo.StreamingLevels[i].PackageName));
        }
      }
    }
  
    // GameInfo 클래스 저장
    GameInfoClassName = PathName(WorldInfo.Game.Class);
  }
  

SaveGameStateInterface 를 JSon 으로 구현하는 모든 액터를 Serialize

다이내믹 액터만 serialize 해 주면 되기에, 여기서 선택한 이터레이터는 DynamicActors 입니다. SaveGameStateInterface 에 대한 필터도 추가했는데, 그래야 어느 다이내믹 액터를 serialize 할지 말지 결정할 수 있기 때문입니다. 여기서 인터페이스를 사용하는 이유는 나중에 SaveGameStgate 를 확장하기 좋기 때문이고, 또 바로 그 액터가 나중에 JSon 데이터를 serialize 하고 deserialize 할 액터이기 때문입니다. SaveGameStateInterface 를 구현하는 액터가 스스로 serialize 하라는 요청을 받으면, 인코딩된 JSon 스트링을 반환합니다. 이 스트링은 SerializedActorData 배열에 추가되고, BasicSaveObject() 가 이 배열을 저장합니다.

SaveGameState.uc
  /**
  * SaveGameStateInterface, Kismet, Matinee 를 구현하는 모든 액터를 serialize 하여 게임 상태를 저장
   */
  function SaveGameState()
  {
    local WorldInfo WorldInfo;
    local Actor Actor;
    local String SerializedActorData;
    local SaveGameStateInterface SaveGameStateInterface;
    local int i;
  
    // 월드 인포를 구하고, 찾지 못하면 중단
    WorldInfo = class'WorldInfo'.static.GetWorldInfo();
    if (WorldInfo == None)
    {
      return;
    }
  
    // 퍼시스턴트 맵 파일명 저장
    PersistentMapFileName = String(WorldInfo.GetPackageName());
  
    // 현재 스트림 인된 맵 파일명 저장
    if (WorldInfo.StreamingLevels.Length > 0)
    {
      // 스트리밍 레벨을 대상으로 반복처리
      for (i = 0; i < WorldInfo.StreamingLevels.Length; ++i)
      {
        // 보이거나 대기중인 로드 요청이 있는 레벨은 스트리밍 레벨 목록에 포함
        if (WorldInfo.StreamingLevels[i] != None && (WorldInfo.StreamingLevels[i].bIsVisible || WorldInfo.StreamingLevels[i].bHasLoadRequestPending))
        {
          StreamingMapFileNames.AddItem(String(WorldInfo.StreamingLevels[i].PackageName));
        }
      }
    }
  
    // GameInfo 클래스 저장
    GameInfoClassName = PathName(WorldInfo.Game.Class);
  
    // SaveGameStateInterface 를 구현하는 모든 액터를 대상으로 반복처리후 스스로 serialize 하라고 요청
    ForEach WorldInfo.DynamicActors(class'Actor', Actor, class'SaveGameStateInterface')
    {
      // SaveGameStateInterface 로 형 변환
      SaveGameStateInterface = SaveGameStateInterface(Actor);
      if (SaveGameStateInterface != None)
      {
        // 액터 Serialize
        SerializedActorData = SaveGameStateInterface.Serialize();
        // SerializedActorData 가 있으면, SerializedWorldData 배열에 추가
        if (SerializedActorData != "")
        {
          SerializedWorldData.AddItem(SerializedActorData);
        }
      }
    }
  }
  

SaveGameStateInterface 는 매우 간단합니다. 여기엔 이를 구현하는 모든 액터가 구현해야 하는 함수가 둘 있습니다. 로딩 시간에 액터가 요청하는 모든 데이터 serialize 를 담당하는 Serialize(), 이른 시점에 저장된 JSon 데이터를 읽고 알맞는 값을 복원하는 Deserialize() 입니다.

SaveGameStateInterface.uc
  interface SaveGameStateInterface;
  
  /**
   * 액터의 데이터를 JSon 속으로 Serialize
   *
   * @return  이 액터의 상태를 나타내는 JSon 데이터
   */
  function String Serialize();
  
  /**
   * 주어진 데이터에서 액터를 Deserialize
   *
   * @param  Data  이 액터의 상태 차이를 나타내는 JSon 데이터
   */
  function Deserialize(JSonObject Data);
  

키즈멧과 마티네를 JSon 으로 Serialize

SaveGameState 는 키즈멧 이벤트와 키즈멧 변수를 serialize 하는 것도 가능합니다. 이를 통해 게임 디자이너가 키즈멧을 사용하여 게임의 일부를 구현할 수 있습니다. 이 작업은 레벨의 키즈멧 이벤트와 키즈멧 변수를 대상으로 반복처리한 후 각각을 serialize 하는 식으로 이루어집니다.

키즈멧 이벤트는 자신의 ActivationTime 으로 오프셋을 계산합니다. 저장된 게임 상태가 다시 로드될 때, WorldInfo.TimeSeconds 는 보통 0 이나 아주 작은 수입니다. 지난 번 게임이 저장되었던 시점일 리는 별로 없지요. 키즈멧 이벤트에 ReTriggerDelay 변수가 설정되어 있다면, ActivationTime 이 가장 중요합니다. 고로 세이브와 로드로 인해 키즈멧 이벤트가 너무 빨리 리트리거되는 버그를 방지하려면, ReTriggerDelay 를 고려하여 ActivationTime 에서 나머지 시간을 계산해 줄 필요가 있습니다. 이런 식으로 키즈멧 이벤트가 다시 로드될 때 ActivationTime 가 트리거되었다면 보통 미래의 시점에 설정되어 있을 것입니다. 이 외에도 TriggerCount 값이 저장되는데, 보통 MaxTriggerCount 가 0 이외의 값으로 설정된 트리거에 필요한 것입니다.

키즈멧 변수는 형변환 시행착오 방법을 사용해서 알아냅니다. 다른 방법으로는 키즈멧 시퀸스 오브젝트를 대상으로 각각의 키즈멧 변수 종류를 찾아 헤매며 반복처리하는 방법이 있겠습니다. 어느 접근법이든 괜찮아요. 키즈멧 변수를 알아내고나면 그 값을 serialize 합니다.

SaveGameState.uc
  /**
   * 키즈멧 게임 상태 저장.
   */
  protected function SaveKismetState()
  {
    local WorldInfo WorldInfo;
    local array<Sequence> RootSequences;
    local array<SequenceObject> SequenceObjects;
    local SequenceEvent SequenceEvent;
    local SeqVar_Bool SeqVar_Bool;
    local SeqVar_Float SeqVar_Float;
    local SeqVar_Int SeqVar_Int;
    local SeqVar_Object SeqVar_Object;
    local SeqVar_String SeqVar_String;
    local SeqVar_Vector SeqVar_Vector;
    local int i, j;
    local JSonObject JSonObject;
  
    // 월드 인포를 구하고, 존재하지 않으면 중단.
    WorldInfo = class'WorldInfo'.static.GetWorldInfo();
    if (WorldInfo == None)
    {
      return;
    }
  
    // 월드 안에 있는 모든 루트 시퀸스를 구하고, 없으면 중단.
    RootSequences = WorldInfo.GetAllRootSequences();
    if (RootSequences.Length <= 0)
    {
      return;
    }
  
    // 모든 시퀸스 이벤트와 시퀸스 변수 Serialize
    for (i = 0; i < RootSequences.Length; ++i)
    {
      if (RootSequences[i] != None)
      {
        // 키즈멧 이벤트 Serialize
        RootSequences[i].FindSeqObjectsByClass(class'SequenceEvent', true, SequenceObjects);
        if (SequenceObjects.Length > 0)
        {
          for (j = 0; j < SequenceObjects.Length; ++j)
          {
            SequenceEvent = SequenceEvent(SequenceObjects[j]);
            if (SequenceEvent != None)
            {
              JSonObject = new () class'JSonObject';
              if (JSonObject != None)
              {
                // 나중에 찾을 수 있도록 SequenceEvent 경로명 저장.
                JSonObject.SetStringValue("Name", PathName(SequenceEvent));
                // 저장된 게임 상태를 로드할 때 활성화 시간이 어때야 하는지를, 리트리거 딜레이에서 현재 월드 시간과 지난 활성화 시간 차를 빼서 계산.
                // 결과가 음수면 트리거된 적이 없다는 뜻이므로, 항상 0 이상인지 확인.
                JsonObject.SetFloatValue("ActivationTime", FMax(SequenceEvent.ReTriggerDelay - (WorldInfo.TimeSeconds - SequenceEvent.ActivationTime), 0.f));
                // 현재 트리거 횟수 저장.
                JSonObject.SetIntValue("TriggerCount", SequenceEvent.TriggerCount);
                // 이 값을 인코딩하여 SaveGameData 배열에 덧붙임.
                SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
              }
            }
          }
        }
  
        // 키즈멧 변수 Serialize
        RootSequences[i].FindSeqObjectsByClass(class'SequenceVariable', true, SequenceObjects);
        if (SequenceObjects.Length > 0)
        {
          for (j = 0; j < SequenceObjects.Length; ++j)
          {
            // 불리언 값으로 serialize 시도.
            SeqVar_Bool = SeqVar_Bool(SequenceObjects[j]);
            if (SeqVar_Bool != None)
            {
              JSonObject = new () class'JSonObject';
              if (JSonObject != None)
              {
                // 나중에 찾을 수 있도록 SeqVar_Bool 경로명 저장.
                JSonObject.SetStringValue("Name", PathName(SeqVar_Bool));
                // 불리언 값 저장.
                JSonObject.SetIntValue("Value", SeqVar_Bool.bValue);
                // 이 값을 인코딩하여 SaveGameData 배열에 덧붙임.
                SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
              }
  
              // 이 배열 인덱스에서는 끝났으니 배열 내 다음 인덱스부터 이어서.
              continue;
            }
  
            // float 값으로 serialize 시도.
            SeqVar_Float = SeqVar_Float(SequenceObjects[j]);
            if (SeqVar_Float != None)
            {
              JSonObject = new () class'JSonObject';
              if (JSonObject != None)
              {
                // 나중에 찾을 수 있도록 SeqVar_Float 경로명 저장.
                JSonObject.SetStringValue("Name", PathName(SeqVar_Float));
                // Float 값 저장.
                JSonObject.SetFloatValue("Value", SeqVar_Float.FloatValue);
                // 이 값을 인코딩하여 SaveGameData 배열에 덧붙임.
                SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
              }
  
              // 이 배열 인덱스에서는 끝났으니 배열 내 다음 인덱스부터 이어서.
              continue;
            }
  
            // int 값으로 serialize 시도.
            SeqVar_Int = SeqVar_Int(SequenceObjects[j]);
            if (SeqVar_Int != None)
            {
              JSonObject = new () class'JSonObject';
              if (JSonObject != None)
              {
                // 나중에 찾을 수 있도록 SeqVar_Int 경로명 저장.
                JSonObject.SetStringValue("Name", PathName(SeqVar_Int));
                // int 값 저장.
                JSonObject.SetIntValue("Value", SeqVar_Int.IntValue);
                // 이 값을 인코딩하여 SaveGameData 배열에 덧붙임.
                SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
              }
  
              // 이 배열 인덱스에서는 끝났으니 배열 내 다음 인덱스부터 이어서.
              continue;
            }
  
            // 오브젝트 변수값으로 serialize 시도.
            SeqVar_Object = SeqVar_Object(SequenceObjects[j]);
            if (SeqVar_Object != None)
            {
              JSonObject = new () class'JSonObject';
              if (JSonObject != None)
              {
                // 나중에 찾을 수 있도록 SeqVar_Object 경로명 저장.
                JSonObject.SetStringValue("Name", PathName(SeqVar_Object));
                // 오브젝트 값 저장.
                JSonObject.SetStringValue("Value", PathName(SeqVar_Object.GetObjectValue()));
                // 이 값을 인코딩하여 SaveGameData 배열에 덧붙임.
                SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
              }
  
              // 이 배열 인덱스에서는 끝났으니 배열 내 다음 인덱스부터 이어서.
              continue;
            }
  
            // 스트링 값으로 serialize 시도.
            SeqVar_String = SeqVar_String(SequenceObjects[j]);
            if (SeqVar_String != None)
            {
              JSonObject = new () class'JSonObject';
              if (JSonObject != None)
              {
                // 나중에 찾을 수 있도록 SeqVar_String 경로명 저장.
                JSonObject.SetStringValue("Name", PathName(SeqVar_String));
                // 스트링 값 저장.
                JSonObject.SetStringValue("Value", SeqVar_String.StrValue);
                // 이 값을 인코딩하여 SaveGameData 배열에 덧붙임.
                SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
              }
  
              // 이 배열 인덱스에서는 끝났으니 배열 내 다음 인덱스부터 이어서.
              continue;
            }
  
            // 벡터 값으로 serialize 시도.
            SeqVar_Vector = SeqVar_Vector(SequenceObjects[j]);
            if (SeqVar_Vector != None)
            {
              JSonObject = new () class'JSonObject';
              if (JSonObject != None)
              {
                // 나중에 찾을 수 있도록 SeqVar_Vector 경로명 저장.
                JSonObject.SetStringValue("Name", PathName(SeqVar_Vector));
                // 벡터 값 저장.
                JSonObject.SetFloatValue("Value_X", SeqVar_Vector.VectValue.X);
                JSonObject.SetFloatValue("Value_Y", SeqVar_Vector.VectValue.Y);
                JSonObject.SetFloatValue("Value_Z", SeqVar_Vector.VectValue.Z);
                // 이 값을 인코딩하여 SaveGameData 배열에 덧붙임.
                SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
              }
  
              // 이 배열 인덱스에서는 끝났으니 배열 내 다음 인덱스부터 이어서.
              continue;
            }
          }
        }
      }
    }
  }
  

마티네도 저장하는 것이 마티네 키즈멧 시퀸스 액션이니, 키즈멧과 같은 식으로 저장됩니다. 즉 모든 키즈멧 시퀸스 오브젝트를 대상으로 반복처리하고, SeqAct_Interp 클래스에 대해 필터링 작업을 합니다. 그런 다음 관련 변수를 serialize 시킨 후 SerializedWorldData 배열에 추가합니다.

SaveGameState.uc
  /**
   * 마티네 게임 상태 저장.
   */
  protected function SaveMatineeState()
  {
    local WorldInfo WorldInfo;
    local array<Sequence> RootSequences;
    local array<SequenceObject> SequenceObjects;
    local SeqAct_Interp SeqAct_Interp;
    local int i, j;
    local JSonObject JSonObject;
  
    // 월드 인포를 구하고, 존재하지 않으면 중단.
    WorldInfo = class'WorldInfo'.static.GetWorldInfo();
    if (WorldInfo == None)
    {
      return;
    }
  
    //   월드 안의 모든 루트 시퀸스를 구하고, 없으면 중단.
    RootSequences = WorldInfo.GetAllRootSequences();
    if (RootSequences.Length <= 0)
    {
      return;
    }
  
    // 모든 시퀸스 이벤트와 시퀸스 변수 Serialize.
    for (i = 0; i < RootSequences.Length; ++i)
    {
      if (RootSequences[i] != None)
      {
        // 마티네 키즈멧 시퀸스 액션 Serialize.
        RootSequences[i].FindSeqObjectsByClass(class'SeqAct_Interp', true, SequenceObjects);
        if (SequenceObjects.Length > 0)
        {
          for (j = 0; j < SequenceObjects.Length; ++j)
          {
            SeqAct_Interp = SeqAct_Interp(SequenceObjects[j]);
            if (SeqAct_Interp != None)
            {
              // 데이터 Serialize 시도.
              JSonObject = new () class'JSonObject';
              if (JSonObject != None)
              {
                // 나중에 찾을 수 있도록 SeqAct_Interp 경로명 저장.
                JSonObject.SetStringValue("Name", PathName(SeqAct_Interp));
                // SeqAct_Interp 의 현위치 저장.
                JSonObject.SetFloatValue("Position", SeqAct_Interp.Position);
                // SeqAct_Interp 재생 여부 저장.
                JSonObject.SetIntValue("IsPlaying", (SeqAct_Interp.bIsPlaying) ? 1 : 0);
                // SeqAct_Interp 일시정지 여부 저장.
                JSonObject.SetIntValue("Paused", (SeqAct_Interp.bPaused) ? 1 : 0);
                // 이 값을 인코딩하여 SaveGameData 배열에 덧붙임.
                SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
              }
            }
          }
        }
      }
    }
  }
  

BasicSaveObject 를 사용하여 데이터 저장

앞서 보인 것처럼 SaveGameState 데이터는 BasicSaveObject() 에 의해 저장됩니다. BasicSaveObject() 는 파일 작성 성공 여부에 따라 참 또는 거짓을 반환합니다. 이를 통해 게임 저장에 성공했는지 아닌지 메시지를 표시할 수 있습니다.

SaveGameStatePlayerController.uc
  /**
   * 게임 상태를 지정된 파일명에 저장하는 실행 함수.
   *
   * @param      FileName      SaveGameState 를 저장할 파일명.
   */
  exec function SaveGameState(string FileName)
  {
    local SaveGameState SaveGameState;
  
    // SaveGameState 인스턴싱.
    SaveGameState = new () class'SaveGameState';
    if (SaveGameState == None)
    {
      return;
    }
  
    // 파일명 세척.
    FileName = ScrubFileName(FileName);
  
    // SaveGameState 더러 게임을 저장하라 요청.
    SaveGameState.SaveGameState();
  
    // SaveGameState 오브젝트를 디스크 상에 Serialize.
    if (class'Engine'.static.BasicSaveObject(SaveGameState, FileName, true, class'SaveGameState'.const.SAVEGAMESTATE_REVISION))
    {
      // 성공하면 메시지 전송.
      ClientMessage("Saved game state to "$FileName$".", 'System');
    }
  }
  


플레이어가 저장된 게임 상태에서 게임 로드

LoadGameState() 는 저장된 게임 상태 로드 시작점입니다. 다시, 이 함수는 클래스 인스턴스에 따라 달라지지 않기에 스태틱 함수로 만들어도 됩니다.

SaveGameStatePlayerController.uc
  /**
   * 지정된 파일명에서 게임 상태를 로드하는 실행 함수.
   *
   * @param    FileName    SaveGameState 를 로드할 파일명.
   */
  exec function LoadGameState(string FileName);
  

저장된 게임 상태 오브젝트를 로드

저장된 게임 상태 오브젝트는 BasicLoadObject() 를 사용하여 디스크에서 처음 로드됩니다.

SaveGameStatePlayerController.uc
  /**
   * 지정된 파일명에서 게임 상태를 로드하는 실행 함수.
   *
   * @param    FileName    SaveGameState 를 로드할 파일명.
   */
  exec function LoadGameState(string FileName)
  {
    local SaveGameState SaveGameState;
  
    // SaveGameState 인스턴싱.
    SaveGameState = new () class'SaveGameState';
    if (SaveGameState == None)
    {
      return;
    }
  
    // 파일명 세척.
    FileName = ScrubFileName(FileName);
  
    // 디스크에서 SaveGameState 오브젝트 Deserialize 시도.
    if (class'Engine'.static.BasicLoadObject(SaveGameState, FileName, true, class'SaveGameState'.const.SAVEGAMESTATE_REVISION))
    {
    }
  }
  

저장된 게임 상태 파일명을 보관하는 명령줄을 덧붙여 맵 로드

저장된 게임 상태 오브젝트가 성공적으로 로드되면, 맵 로딩 완료시 정의된 게임 상태 저장내용을 계속해서 로드하라고 이르는 명령줄 파라미터를 덧붙여 Serialize 된 맵을 로드합니다. 이 함수를 스태틱 함수로 만들기로 했다면, 전역적으로 리퍼런스 가능한 다른 액터에서 ConsoleCommand() 를 호출할 수 있습니다.

ALERT! : 여기서는 'open' 대신 'start' 콘솔 명령이 사용됩니다. 그 이유는 'start' 는 항상 명령줄 파라미터를 리셋시키는 반면, 'open' 은 명령줄 파라미터에 '덧붙이기' 때문입니다. 이는 매우 중요한데, 명령줄 파라미터가 잘못되면 "SaveGameState" 가 여러번 덧붙어 로딩이 잘못될 것이기 때문입니다!

SaveGameStatePlayerController.uc
  /**
   * 지정된 파일명에서 게임 상태를 로드하는 실행 함수.
   *
   * @param    FileName    SaveGameState 를 로드할 파일명.
   */
  exec function LoadGameState(string FileName)
  {
    local SaveGameState SaveGameState;
  
    // SaveGameState 인스턴싱.
    SaveGameState = new () class'SaveGameState';
    if (SaveGameState == None)
    {
      return;
    }
  
    // 파일명 세척.
    FileName = ScrubFileName(FileName);
  
    // 디스크에서 SaveGameState 오브젝트 Deserialize 시도.
    if (class'Engine'.static.BasicLoadObject(SaveGameState, FileName, true, class'SaveGameState'.const.SAVEGAMESTATE_REVISION))
    {
      // 요청받은 명령줄 파라미터로 맵을 시작한 후 SaveGameState 로드.
      ConsoleCommand("start "$SaveGameState.PersistentMapFileName$"?Game="$SaveGameState.GameInfoClassName$"?SaveGameState="$FileName);
    }
  }
  

맵 로딩이 끝나면 저장된 게임 상태 오브젝트를 다시 로드

맵이 로드되면 SaveStateGameInfo::InitGame() 는 SaveGameState 명령줄 파라미터가 있는지 없는지 골라냅니다. 그런 다음 PendingSaveGameFileName 안에 값을 저장합니다. 그 후 경기가 시작될 때, SaveGameState 오브젝트를 디스크에서 다시 로드한 다음 게임 상태를 로드하라 요청합니다. 저장된 게임 상태가 로드되면, 저장된 게임이 로드되었다 플레이어에게 알리는 메시지가 전송됩니다. 스트리밍 레벨이 있다면, SaveStateGameInfo::StartMatch() 는 다른 맵을 스트림 인 하라고 모든 플레이어 콘트롤러에 이릅니다. 그러나 다른 맵을 스트림 인 시키는 작업은 같은 틱에 끝나지 않을 것이기에, 모든 스트리밍 레벨 로딩이 언제 끝나는지 검사하기 위해 SaveStateGameInfo::WaitingForStreamingLevelsTimer() 라는 루핑 타이머를 구성합니다. 스트리밍 맵 로딩이 끝나면 Super.StartMatch() [UTGame::StartMatch()] 를 호출하여 경기를 시작합니다.

SaveGameStateGameInfo.uc
  class SaveGameStateGameInfo extends UTGame;
  
  // 대기중인 SaveGameState 파일명
  var private string PendingSaveGameFileName;
  
  /*
   * 게임 초기화. GameInfo 의 InitGame() 함수는 (PreBeginPlay() 를 포함해서) 다른 스크립트 이전에 호출되며, GameInfo 가 파라미터를 초기화하고 그 헬퍼 클래스를 스폰하는 데 사용되기도 합니다.
   *
   * @param    Options        명령줄에서 전달된 옵션.
   * @param    ErrorMessage    출력되는 에러 메시지.
   */
  event InitGame(string Options, out string ErrorMessage)
  {
    Super.InitGame(Options, ErrorMessage);
  
    // 필요하면 대기중인 세이브 게임 파일명을 설정.
    if (HasOption(Options, "SaveGameState"))
    {
      PendingSaveGameFileName = ParseOption(Options, "SaveGameState");
    }
    else
    {
      PendingSaveGameFileName = "";
    }
  }
  
  /**
   * 경기를 시작. 경기가 시작한다 모든 액터에게 알리고, 플레이어 폰을 스폰.
   */
  function StartMatch()
  {
    local SaveGameState SaveGameState;
    local PlayerController PlayerController;
    local int i;
  
    // 게임을 로드해야 하는지 확인.
    if (PendingSaveGameFileName != "")
    {
      // SaveGameState 인스턴싱.
      SaveGameState = new () class'SaveGameState';
      if (SaveGameState == None)
      {
        return;
      }
  
      // 디스크에서 SaveGameState 오브젝트 Deserialize 시도.
      if (class'Engine'.static.BasicLoadObject(SaveGameState, PendingSaveGameFileName, true, class'SaveGameState'.const.SAVEGAMESTATE_REVISION))
      {
        // 스트리밍 레벨 동기 로드.
        if (SaveGameState.StreamingMapFileNames.Length > 0)
        {
          // 모든 플레이어 콘트롤러에게 스트리밍 맵을 로드하라 이름.
          ForEach WorldInfo.AllControllers(class'PlayerController', PlayerController)
          {
            // 맵 파일 스트리밍 시작.
            for (i = 0; i < SaveGameState.StreamingMapFileNames.Length; ++i)
            {
              PlayerController.ClientUpdateLevelStreamingStatus(Name(SaveGameState.StreamingMapFileNames[i]), true, true, true);
            }
  
            // 대기중이던 로딩 완료시까지 모두 막기.
            PlayerController.ClientFlushLevelStreaming();
          }
  
          // SaveGameState 를 StreamingSaveGameState 에 보관
          StreamingSaveGameState = SaveGameState;
          // 모든 스트리밍 레벨 로딩이 끝날때까지 기다리는 루핑 타이머 시작
          SetTimer(0.05f, true, NameOf(WaitingForStreamingLevelsTimer));
          return;
        }
  
        // 게임 상태 로드
        SaveGameState.LoadGameState();
      }
  
      // 모든 플레이어 콘트롤러에게 SaveGameState 를 로드했다는 메시지 전송.
      ForEach WorldInfo.AllControllers(class'PlayerController', PlayerController)
      {
        PlayerController.ClientMessage("Loaded SaveGameState from "$PendingSaveGameFileName$".", 'System');
  
    Super.StartMatch();
  }
  
  function WaitingForStreamingLevelsTimer()
        {
    local int i;
    local PlayerController PlayerController;
  
    for (i = 0; i < WorldInfo.StreamingLevels.Length; ++i)
          {
      // If any levels still have the load request pending, then return
      if (WorldInfo.StreamingLevels[i].bHasLoadRequestPending)
            {
        return;
        }
      }
  
    // Clear the looping timer
    ClearTimer(NameOf(WaitingForStreamingLevelsTimer));
  
    // Load the save game state
    StreamingSaveGameState.LoadGameState();
    // Clear it for garbage collection
    StreamingSaveGameState = None;
  
    // Send a message to all player controllers that we've loaded the save game state
    ForEach WorldInfo.AllControllers(class'PlayerController', PlayerController)
    {
      PlayerController.ClientMessage("Loaded save game state from "$PendingSaveGameFileName$".", 'System');
    }
  
    // Start the match
    Super.StartMatch();
  }
  

JSon 데이터를 반복처리하여 레벨 안에 있는 액터와 오브젝트에 데이터를 Deserialize

이제 저장된 게임 상태 오브젝트 로드를 마쳤으니, SaveGameStateInterface, Kismet, Matinee 를 구현하는 액터를 대상으로 반복처리한 다음 SerializedWorldData 배열에 (JSon 으로 인코딩되어) 보관되어 있는 데이터에 따라 복원시킵니다.

SerializedWorldData 의 반복처리 과정에서 각 항목은 JSonObject 로 디코딩됩니다. Name 을 검색하면 JSonObject 데이터가 무엇에 관련되어 있는지 파악하는데 도움이 되는 정보를 약간 얻을 수 있습니다. SeqAct_Interp 에 대한 테스트를 통해 데이터가 키즈멧 이벤트나 키즈멧 변수 중 하나에 대한 마티네 오브젝트용인지, SeqEvent 용인지, SeqVar 용인지가 드러납니다. 세 가지 모두 아니라면 월드에 있는 액터용일 것입니다.

JSonObject 데이터가 월드에 있는 액터용이라면, 그 액터는 FindObject() 로 검색됩니다. 액터의 전체 경로명이 보관되니, FindObject() 는 레벨 디자이너가 놓은 액터는 무엇이든 찾을 수 있을 테지요. FindObject() 가 실패하면, 플레이 도중 인스턴싱된 액터용일 것입니다. 필요하면 저장된 게임 상태로 다시 인스턴싱할 수 있도록 ObjectArchetype 도 보관하는 것이 좋기 때문입니다. 액터나 그 인스턴스를 찾았으면, 그 액터를 SaveGameStateInterface 로 형 변환한 다음 JSonObject 에 보관된 데이터에 따라 자체 Deserialize 요청을 합니다.

SaveGameState.uc
  /**
   * Serialize 된 모든 데이터를 Deserialize 한 데이터를 SaveGameStateInterface, 키즈멧, 마티네를 구현하는 액터에 적용하여 게임 상태를 로드.
   */
  function LoadGameState()
  {
    local WorldInfo WorldInfo;
    local int i;
    local JSonObject JSonObject;
    local String ObjectName;
    local SaveGameStateInterface SaveGameStateInterface;
    local Actor Actor, ActorArchetype;
  
    // 로드할 Serialize 된 월드 데이터 없음!
    if (SerializedWorldData.Length <= 0)
    {
      return;
    }
  
    // 월드 인포를 구하고, 없으면 중단.
    WorldInfo = class'WorldInfo'.static.GetWorldInfo();
    if (WorldInfo == None)
    {
      return;
    }
  
    // 각각의 Serialize 된 데이터 오브젝트에 대해
    for (i = 0; i < SerializedWorldData.Length; ++i)
    {
      if (SerializedWorldData[i] != "")
      {
        // 인코딩된 스트링에서 JSonObject 를 디코드.
        JSonObject = class'JSonObject'.static.DecodeJson(SerializedWorldData[i]);
        if (JSonObject != None)
        {
          // 오브젝트 이름 구하기.
          ObjectName = JSonObject.GetStringValue("Name");
          // 오브젝트 이름에 SeqAct_Interp 가 포함되어 있는지 확인 후 그렇다면 마티네 Deserialize.
          if (InStr(ObjectName, "SeqAct_Interp",, true) != INDEX_NONE)
          {
            LoadMatineeState(ObjectName, JSonObject);
          }
          // 오브젝트 이름에 SeqEvent 나 SeqVar 가 포함되어 있는지 확인 후 그렇다면 키즈멧 Deserialize.
          else if (InStr(ObjectName, "SeqEvent",, true) != INDEX_NONE || InStr(ObjectName, "SeqVar",, true) != INDEX_NONE)
          {
            LoadKismetState(ObjectName, JSonObject);
          }
          // 그 이외에는 다른 종류의 액터.
          else
          {
            // 퍼시스턴트 레벨 액터 찾기 시도.
            Actor = Actor(FindObject(ObjectName, class'Actor'));
  
            // 액터가 퍼시스턴트 레벨에 있지 않다면 휘발성이었을 것이며, 스폰 시도.
            if (Actor == None)
            {
              // 액터 스폰.
              ActorArchetype = GetActorArchetypeFromName(JSonObject.GetStringValue("ObjectArchetype"));
              if (ActorArchetype != None)
              {
                Actor = WorldInfo.Spawn(ActorArchetype.Class,,,,, ActorArchetype, true);
              }
            }
  
            if (Actor != None)
            {
              // SaveGameStateInterface 로 형 변환
              SaveGameStateInterface = SaveGameStateInterface(Actor);
              if (SaveGameStateInterface != None)
              {
                // 액터 Deserialize
                SaveGameStateInterface.Deserialize(JSonObject);
              }
            }
          }
        }
      }
    }
  }
  
  /**
   * 이름에서 액터 아키타입 반환.
   *
   * @return    스트링 표현에서 액터 아키타입 반환.
   */
  function Actor GetActorArchetypeFromName(string ObjectArchetypeName)
  {
    local WorldInfo WorldInfo;
  
    WorldInfo = class'WorldInfo'.static.GetWorldInfo();
    if (WorldInfo == None)
    {
      return None;
    }
  
    // 콘솔에서라면 스태틱 룩업 사용, 스태틱 룩업 작동을 위해서는
    //  * 클래스나 패키징된 아키타입을 맵에다 강제 쿠킹
    //  * 패키징된 아키타입을 StartupPackage 리스트에 추가
    //  * Unrealscript 내 어딘가의 패키지 아키타입 참조
    if (WorldInfo.IsConsoleBuild())
    {
      return Actor(FindObject(ObjectArchetypeName, class'Actor'));
    }
    else // PC 에서라면 다이내믹 룩업 사용
    {
      return Actor(DynamicLoadObject(ObjectArchetypeName, class'Actor'));
    }
  }
  

키즈멧 Deserialize 작업은 키즈멧 시퀸스 오브젝트를 찾을 수 없는 경우 Unrealscript 가 인스턴싱 시도를 하지 않는다는 점만 제외하고는 액터 Deserialize 작업과 같은 식으로 이루어집니다. FindObject() 로 키즈멧 시퀸스 오브젝트를 찾았다면, 정확히 무엇인지 알아내기 위해 형 변환을 합니다. 거기서 JSonObject 로부터 저장된 값을 복원합니다.

SaveGameState.uc
  /**
   * 지정된 데이터에 따라 키즈멧 시퀸스 상태 로드.
   *
   * @param    ObjectName    레벨에 있는 키즈멧 오브젝트 이름.
   * @param    Data      키즈멧 오브젝트에 대한 JSon 으로써의 데이터
   */
  function LoadKismetState(string ObjectName, JSonObject Data)
  {
    local SequenceEvent SequenceEvent;
    local SeqVar_Bool SeqVar_Bool;
    local SeqVar_Float SeqVar_Float;
    local SeqVar_Int SeqVar_Int;
    local SeqVar_Object SeqVar_Object;
    local SeqVar_String SeqVar_String;
    local SeqVar_Vector SeqVar_Vector;
    local Object SequenceObject;
    local WorldInfo WorldInfo;
  
    // 시퀸스 오브젝트 검색 시도.
    SequenceObject = FindObject(ObjectName, class'Object');
  
    // 시퀸스 오브젝트를 찾지 못했다면 중단.
    if (SequenceObject == None)
    {
      return;
    }
  
    // 키즈멧 이벤트 Deserialize
    SequenceEvent = SequenceEvent(SequenceObject);
    if (SequenceEvent != None)
    {
      WorldInfo = class'WorldInfo'.static.GetWorldInfo();
      if (WorldInfo != None)
      {
        SequenceEvent.ActivationTime = WorldInfo.TimeSeconds + Data.GetFloatValue("ActivationTime");
      }
  
      SequenceEvent.TriggerCount = Data.GetIntValue("TriggerCount");
      return;
    }
  
    // 키즈멧 변수 불리언 Deserialize
    SeqVar_Bool = SeqVar_Bool(SequenceObject);
    if (SeqVar_Bool != None)
    {
      SeqVar_Bool.bValue = Data.GetIntValue("Value");
      return;
    }
  
    // 키즈멧 변수 플로트 Deserialize
    SeqVar_Float = SeqVar_Float(SequenceObject);
    if (SeqVar_Float != None)
    {
      SeqVar_Float.FloatValue = Data.GetFloatValue("Value");
      return;
    }
  
    // 키즈멧 변수 인트 Deserialize
    SeqVar_Int = SeqVar_Int(SequenceObject);
    if (SeqVar_Int != None)
    {
      SeqVar_Int.IntValue = Data.GetIntValue("Value");
      return;
    }
  
    // 키즈멧 변수 오브젝트 Deserialize
    SeqVar_Object = SeqVar_Object(SequenceObject);
    if (SeqVar_Object != None)
    {
      SeqVar_Object.SetObjectValue(FindObject(Data.GetStringValue("Value"), class'Object'));
      return;
    }
  
    // 키즈멧 변수 스트링 Deserialize
    SeqVar_String = SeqVar_String(SequenceObject);
    if (SeqVar_String != None)
    {
      SeqVar_String.StrValue = Data.GetStringValue("Value");
      return;
    }
  
    // 키즈멧 변수 벡터 Deserialize
    SeqVar_Vector = SeqVar_Vector(SequenceObject);
    if (SeqVar_Vector != None)
    {
      SeqVar_Vector.VectValue.X = Data.GetFloatValue("Value_X");
      SeqVar_Vector.VectValue.Y = Data.GetFloatValue("Value_Y");
      SeqVar_Vector.VectValue.Z = Data.GetFloatValue("Value_Z");
      return;
    }
  }
  

마티네 Deserialize 작업은 키즈멧과 비슷합니다. 그러나 SaveGameState 가 저장되던 시점에 마티네 시퀸스가 재생되고 있었다면, JSonObject 안에 IsPlaying 이 1 로 보관됩니다. 따라서 ForceStartPosition 이 설정되고 마티네가 재생 요청을 받습니다. 아니었다면 마티네는 JSonObject 안에 보관된 Position 값에 따라 그 위치가 설정됩니다.

SaveGameState.uc
  /**
   * 데이터에 따라 마티네 상태를 로드.
   *
   * @param    ObjectName    마티네 키즈멧 오브젝트 이름.
   * @param    Data      저장된 마티네 키즈멧 데이터.
   */
  function LoadMatineeState(string ObjectName, JSonObject Data)
  {
    local SeqAct_Interp SeqAct_Interp;
    local float OldForceStartPosition;
    local bool OldbForceStartPos;
  
    // 마티네 키즈멧 오브젝트 검색.
    SeqAct_Interp = SeqAct_Interp(FindObject(ObjectName, class'Object'));
    if (SeqAct_Interp == None)
    {
      return;
    }
  
    if (Data.GetIntValue("IsPlaying") == 1)
    {
      OldForceStartPosition = SeqAct_Interp.ForceStartPosition;
      OldbForceStartPos = SeqAct_Interp.bForceStartPos;
  
      // 강제된 위치에서 마티네 재생.
      SeqAct_Interp.ForceStartPosition = Data.GetFloatValue("Position");
      SeqAct_Interp.bForceStartPos = true;
      SeqAct_Interp.ForceActivateInput(0);
  
      // start position 과 start pos 리셋.
      SeqAct_Interp.ForceStartPosition = OldForceStartPosition;
      SeqAct_Interp.bForceStartPos = OldbForceStartPos;
    }
    else
    {
      // 마티네 위치 설정.
      SeqAct_Interp.SetPosition(Data.GetFloatValue("Position"), true);
    }
  
    // 일시정지 설정.
    SeqAct_Interp.bPaused = (Data.GetIntValue("Paused") == 1) ? true : false;
  }
  

KActor 예제


SaveGameState 시스템을 사용해서 KActor 를 자체 Serialize / Deserialize 하도록 구성하는 법을 보여주는 예제입니다. 로드/세이브 하자마자 세이브 게임 시스템이 자동으로 집어들게 하고픈 액터 클래스에 대해서는 SaveGameStateInterface 를 구현해 줘야 한다는 점, 기억하시기 바랍니다.

SaveGameStateKActor.uc
  class SaveGameStateKActor extends KActor
    Implements(SaveGameStateInterface);
  

KActor Serialize 하기

여기에는 위치와 회전 값만 저장됩니다. 필요한 데이터는 경로명과 오브젝트 아키타입으로, 없다면 SaveGameState 시스템은 어느 액터나 오브젝트에 데이터를 적용시킬지 알지 못하며, 그 액터나 오브젝트를 인스턴싱할 필요가 있는 경우에도 SaveGameState 시스템은 어느 액터나 오브젝트를 인스턴싱해야 할 지 알지 못할 것입니다.

즉 위치는 플로트 셋으로, 로테이션은 인티저 셋으로 저장됩니다. 물론 필요에 따라 변수를 추가 저장할 수도 있습니다. JSon 이 선택된 이유라면, JSonObject::SetObject() 함수를 사용해서 부모자손 구조를 만들수 있기 때문입니다. 따라서 자손 액터나 오브젝트를 이 단계 안에서 자체적으로 Serialize 되도록 할 수도 (이 액터나 오브젝트가 Serialize 된 적이 있는지 기록을 유지하는 방법을 강구해 둬야 합니다. 안그러면 이 액터나 오브젝트가 두 번 이상 Serialize / Deserialize 될 수도 있습니다.), 부모 데이터 세트와 함께 저장되도록 할 수도 있습니다. 이로써 베이스 SaveGameSystem 코드베이스를 만지지 않고도 아주 쉽게 어태치된 액터나 오브젝트를 처리할 수 있는 방법이 생깁니다.

SaveGameStateKActor.uc
  /**
   * 액터의 데이터를 JSon 으로 Serialize.
   *
   * @return    이 액터의 상태를 나타내는 JSon 데이터
   */
  function String Serialize()
  {
    local JSonObject JSonObject;
  
    // JSonObject 를 인스턴싱, 만들 수 없다면 중단.
    JSonObject = new () class'JSonObject';
    if (JSonObject == None)
    {
      `Warn(Self$" could not be serialized for saving the game state.");
      return "";
    }
  
    // 나중에 찾을 수 있도록 경로명 Serialize
    JSonObject.SetStringValue("Name", PathName(Self));
  
    // 스폰해야 하는 경우를 대비해 오브젝트 아키타입 Serialize
    JSonObject.SetStringValue("ObjectArchetype", PathName(ObjectArchetype));
  
    // 위치 저장
    JSonObject.SetFloatValue("Location_X", Location.X);
    JSonObject.SetFloatValue("Location_Y", Location.Y);
    JSonObject.SetFloatValue("Location_Z", Location.Z);
  
    // 회전 저장
    JSonObject.SetIntValue("Rotation_Pitch", Rotation.Pitch);
    JSonObject.SetIntValue("Rotation_Yaw", Rotation.Yaw);
    JSonObject.SetIntValue("Rotation_Roll", Rotation.Roll);
  
    // 인코딩된 JSonObject 전송
    return class'JSonObject'.static.EncodeJson(JSonObject);
  }
  

KActor Deserialize 하기

KActor 가 스스로 Deserialize 하라고 요청받을 때, 스스로를 Serialize 시킨 JSon 데이터를 받습니다. 따라서 반대 작업을 해 주기만 해도 게임 상태 저장 당시의 상태로 KActor 가 복원될 것입니다. 위에 말한 대로, 자손 액터나 오브젝트의 Serialize 를 해야 하는 경우, 이곳이 그 데이터를 Deserialize 하기에 적합한 곳일 것입니다.

SaveGameStateKActor.uc
  /**
   * 주어진 데이터에서 액터 Deserialize.
   *
   * @param    Data    이 액터의 상태 차를 나타내는 JSon 데이터.
   */
  function Deserialize(JSonObject Data)
  {
    local Vector SavedLocation;
    local Rotator SavedRotation;
  
    // 위치를 Deserialize 하고 설정.
    SavedLocation.X = Data.GetFloatValue("Location_X");
    SavedLocation.Y = Data.GetFloatValue("Location_Y");
    SavedLocation.Z = Data.GetFloatValue("Location_Z");
  
    // 회전을 Deserialize 하고 설정.
    SavedRotation.Pitch = Data.GetIntValue("Rotation_Pitch");
    SavedRotation.Yaw = Data.GetIntValue("Rotation_Yaw");
    SavedRotation.Roll = Data.GetIntValue("Rotation_Roll");
  
    if (StaticMeshComponent != None)
    {
      StaticMeshComponent.SetRBPosition(SavedLocation);
      StaticMeshComponent.SetRBRotation(SavedRotation);
    }
  }
  

플레이어 콘트롤드 폰 예제


플레이어 콘트롤드 폰은 재미난 예제로, 관여된 액터 중 레벨 디자이너가 놓은 것이 없다는, 즉 맵에 PlayerController 도 Pawn 클래스도 놓인 적이 없다는 뜻입니다. 그러나 폰은 일인용 맵에서 적 몬스터를 놓기 위해서라든가, 다른 용도로 레벨 디자이너가 놓을 수도 있습니다. 따라서 여기서 취한 방법은 IsPlayerControlled 라는 추가 플랙을 저장하는 것이었습니다. 즉 세이브 게임 시스템에 의해 폰이 인스턴싱되고 Deserialize 될 때, IsPlayerControlled 가 1 로 설정되어 있다면 Deserialize 코드가 GameInfo 에게 그 사실을 알립니다.

SaveGameStatePlayerController.uc
  /**
   * 액터의 데이터를 JSon 으로 Serialize
   *
   * @return    이 액터의 상태를 나타내는 JSon 데이터
   */
  function String Serialize()
  {
    local JSonObject JSonObject;
  
    // JSonObject 인스턴싱, 만들 수 없으면 중단.
    JSonObject = new () class'JSonObject';
    if (JSonObject == None)
    {
      `Warn(Self$" could not be serialized for saving the game state.");
      return "";
    }
  
    // 나중에 찾을 수 있도록 경로명 Serialize
    JSonObject.SetStringValue("Name", PathName(Self));
  
    // 스폰해야 하는 경우를 대비해 오브젝트 아키타입 Serialize
    JSonObject.SetStringValue("ObjectArchetype", PathName(ObjectArchetype));
  
    // 위치 저장
    JSonObject.SetFloatValue("Location_X", Location.X);
    JSonObject.SetFloatValue("Location_Y", Location.Y);
    JSonObject.SetFloatValue("Location_Z", Location.Z);
  
    // 회전 저장
    JSonObject.SetIntValue("Rotation_Pitch", Rotation.Pitch);
    JSonObject.SetIntValue("Rotation_Yaw", Rotation.Yaw);
    JSonObject.SetIntValue("Rotation_Roll", Rotation.Roll);
  
    // 콘트롤러가 플레이어 콘트롤러라면, 게임 상태를 다시 로드할 때 플레이어가 다시 빙의(possess)해야 함을 알리기 위해 플랙 저장.
    JSonObject.SetIntValue("IsPlayerControlled", (PlayerController(Controller) != None) ? 1 : 0);
  
    // 인코딩된 JSonObject 전송.
    return class'JSonObject'.static.EncodeJson(JSonObject);
  }
  
  /**
   * 주어진 데이터에서 액터 Deserialize.
   *
   * @param    Data    이 액터의 상태 차를 나타내는 JSon 데이터.
   */
  function Deserialize(JSonObject Data)
  {
    local Vector SavedLocation;
    local Rotator SavedRotation;
    local SaveGameStateGameInfo SaveGameStateGameInfo;
  
    // 위치 Deserialize 후 설정
    SavedLocation.X = Data.GetFloatValue("Location_X");
    SavedLocation.Y = Data.GetFloatValue("Location_Y");
    SavedLocation.Z = Data.GetFloatValue("Location_Z");
    SetLocation(SavedLocation);
  
    // 회전 Deserialize 후 설정
    SavedRotation.Pitch = Data.GetIntValue("Rotation_Pitch");
    SavedRotation.Yaw = Data.GetIntValue("Rotation_Yaw");
    SavedRotation.Roll = Data.GetIntValue("Rotation_Roll");
    SetRotation(SavedRotation);
  
    // 이것이 플레이어 콘트롤드 폰이었으면 Deserialize 후 게임인포에 알림.
    if (Data.GetIntValue("IsPlayerControlled") == 1)
    {
      SaveGameStateGameInfo = SaveGameStateGameInfo(WorldInfo.Game);
      if (SaveGameStateGameInfo != None)
      {
        SaveGameStateGameInfo.PendingPlayerPawn = Self;
      }
    }
  }
  

GameInfo::RestartPlayer() 가 호출될 때, 먼저 플레이어 콘트롤러를 기다리며 대기중인 플레이어 폰이 있는지 확인합니다. 있다면 플레이어 콘트롤러를 대신 줍니다.

SaveGameStateGameInfo.uc
  /**
   * 콘트롤러 재시작.
   *
   * @param    NewPlayer    재시작할 플레이어.
   */
  function RestartPlayer(Controller NewPlayer)
  {
    local LocalPlayer LP;
    local PlayerController PC;
  
    // 콘트롤러가 있는지 확인.
    if (NewPlayer == None)
    {
      return;
    }
  
    // 대기중인 플레이어 폰이 있으면 그냥 빙의
    if (PendingPlayerPawn != None)
    {
      // 대기중인 플레이어 폰을 새로운 플레이어의 폰으로 할당
      NewPlayer.Pawn = PendingPlayerPawn;
  
      // 초기화후 시작
      if (PlayerController(NewPlayer) != None)
      {
        PlayerController(NewPlayer).TimeMargin = -0.1;
      }
  
      NewPlayer.Pawn.LastStartTime = WorldInfo.TimeSeconds;
      NewPlayer.Possess(NewPlayer.Pawn, false);
      NewPlayer.ClientSetRotation(NewPlayer.Pawn.Rotation, true);
  
      if (!WorldInfo.bNoDefaultInventoryForPlayer)
      {
        AddDefaultInventory(NewPlayer.Pawn);
      }
  
      SetPlayerDefaults(NewPlayer.Pawn);
  
      // 보류중인 폰 비우기
      PendingPlayerPawn = None;
    }
    else // 아니면 플레이어 빙의용 폰 새로 스폰
    {
      Super.RestartPlayer(NewPlayer);
    }
  
    // 에디터나 PIE 로 실행하지 않을 때 커스텀 포스트 프로세싱 체인 수정.
    PC = PlayerController(NewPlayer);
    if (PC != none)
    {
      LP = LocalPlayer(PC.Player);
  
      if (LP != None)
      {
        LP.RemoveAllPostProcessingChains();
        LP.InsertPostProcessingChain(LP.Outer.GetWorldPostProcessChain(), INDEX_NONE, true);
  
        if (PC.myHUD != None)
        {
          PC.myHUD.NotifyBindPostProcessEffects();
        }
      }
    }
  }
  

이로써 저장된 게임 상태를 로드할 때 플레이어는 PlayerStart 가 아닌 전과 같은 위치에 있게 됩니다.

Game State Loaded 키즈멧 이벤트


가끔은 게임 월드가 완전히 복원되었는지 확인하기 위해 약간의 키즈멧 액션을 내려야 할 수도 있습니다. 커스텀 시퀸스 이벤트를 만드는 식으로 이루어집니다.

SaveGameState_SeqEvent_SavedGameStateLoaded.uc
  class SaveGameState_SeqEvent_SavedGameStateLoaded extends SequenceEvent;
  
  defaultproperties
  {
    ObjName="Saved Game State Loaded"
    MaxTriggerCount=0
    VariableLinks.Empty
    OutputLinks(0)=(LinkDesc="Loaded")
    bPlayerOnly=false
  }
  

그런 다음 GameInfo::StartMatch() 에서 SaveGameState 가 로드될 때 커스텀 시퀸스 이벤트가 트리거됩니다.

SaveGameStateGameInfo.uc
  /**
   * 경기 시작. 모든 액터에게 경기가 시작한다 알리고 플레이어 폰을 스폰.
   */
  function StartMatch()
  {
    local SaveGameState SaveGameState;
    local PlayerController PlayerController;
    local int Idx;
    local array<SequenceObject> Events;
    local SaveGameState_SeqEvent_SavedGameStateLoaded SavedGameStateLoaded;
  
    // 게임을 로드해야 하는지 확인.
    if (PendingSaveGameFileName != "")
    {
      // SaveGameState 인스턴싱.
      SaveGameState = new () class'SaveGameState';
      if (SaveGameState == None)
      {
        return;
      }
  
      // 디스크에서 SaveGameState 오브젝트 Deserialize 시도.
      if (class'Engine'.static.BasicLoadObject(SaveGameState, PendingSaveGameFileName, true, class'SaveGameState'.const.SAVEGAMESTATE_REVISION))
      {
        // 게임 상태 로드.
        SaveGameState.LoadGameState();
      }
  
      // 모든 플레이어 콘트롤러에게 SaveGameState 로드를 마쳤다는 메시지 전송.
      ForEach WorldInfo.AllControllers(class'PlayerController', PlayerController)
      {
        PlayerController.ClientMessage("Loaded SaveGameState from "$PendingSaveGameFileName$".", 'System');
  
        // Saved Game State Loaded 이벤트 발동.
        if (WorldInfo.GetGameSequence() != None)
        {
          WorldInfo.GetGameSequence().FindSeqObjectsByClass(class'SaveGameState_SeqEvent_SavedGameStateLoaded', true, Events);
          for (Idx = 0; Idx < Events.Length; Idx++)
          {
            SavedGameStateLoaded = SaveGameState_SeqEvent_SavedGameStateLoaded(Events[Idx]);
            if (SavedGameStateLoaded != None)
            {
              SavedGameStateLoaded.CheckActivate(PlayerController, PlayerController);
            }
          }
        }
      }
    }
  
    Super.StartMatch();
  }
  

질문


자손 액터나 오브젝트는 어떻게 처리하나요?

JSon 이 선택된 이유라면, JSonObject::SetObject() 함수를 사용해서 부모자손 구조를 만들수 있기 때문입니다. 따라서 자손 액터나 오브젝트를 이 단계 안에서 자체적으로 Serialize 되도록 할 수도 (이 액터나 오브젝트가 Serialize 된 적이 있는지 기록을 유지하는 방법을 강구해 둬야 합니다. 안그러면 이 액터나 오브젝트가 두 번 이상 Serialize / Deserialize 될 수도 있습니다.), 부모 데이터 세트와 함께 저장되도록 할 수도 있습니다. 이로써 베이스 SaveGameSystem 코드베이스를 만지지 않고도 아주 쉽게 어태치된 액터나 오브젝트를 처리할 수 있는 방법이 생깁니다. 액터나 오브젝트가 Deserialize 요청을 받으면, inner JSonObject 를 대상으로 반복처리하여 같은 종류의 Deserialize 를 할 수 있습니다.

게임 상태가 텍스트로 저장됩니다! 플레이어 치트를 어떻게 막지요?

JSon 이 선택된 이유 또 하나는, 메모장같은 단순한 문서 편집기로 열 수 있으니 저장된 게임 상태 파일 디버깅이 아주 쉽기 때문입니다. 그러나 바이너리로 저장되지 않아 치트가 걱정된다는 것도 일리가 있습니다.

여기엔 생각이 꼬리에 꼬리를 물고 이어집니다. text mangler 함수를 통해 인코딩된 JSon 을 전달하여 데이터를 헛갈리게 만들 수도 있죠. 어찌됐든 정말로 저장된 게임을 해킹하려는 사람이라면 어떻게든 풀어낼 것입니다. 바이너리도 예외는 아닙니다.

그러므로 종국에는, 정보원을 확인하고 (온라인 세이브에서) 세이브 데이터가 보관되는 곳을 확인하지 않고서는 치트 방지를 위해 할 수 있는 일은 별로 없습니다.

JSon 데이터를 온라인으로 저장할 수 있나요?

예. 여기에 JSon 이 좋은 점은, TCPLink 를 통해 서버로 전송할 수 있는 상호교환가능 데이터 포맷인 일반 텍스트라는 점입니다. 고로 온라인 어딘가에 세이브 게임을 보관하고, 다른 기계의 클라이언트에서 받아오도록 할 수도 있으며... 심지어 다른 디바이스 에서도 가능합니다. 아니면 웹사이트에서 JSon 데이터를 읽어들여 플레이어의 상황을 보여주도록 할 수도 있습니다. 가능성은 끝도 없지요.

이 UDK 젬을 통합하는 방법은?

(가장 쉽게는) SaveGameState 클래스를 서브클래싱 하거나, SaveGameState 클래스 안의 코드를 게임에 맞게끔 변환하거나 하면 됩니다. 기억하실 것은, 게임에서 올바른 PlayerController 가 사용될 수 있도록 반드시 제대로 된 게임 타입으로 실행해야 한다는 점입니다. 그렇지 않으면 잘못된 클래스가 사용되니 코드가 작동하지 않습니다. 현재 사용되는 GameInfo 와 PlayerController 가 무엇인지를 확인하려면 "showdebug" 콘솔 명령을 사용합니다. 그러면 화면 좌상단에 현재 사용중인 GameInfo 와 PlayerController 가 무엇인지 출력됩니다.

통합하고 맵을 로드했는데 아무것도 일어나지 않아요!

기억하실 것은, 예제 코드에서는 SaveGameState 에 스트리밍 레벨이 있을때 기본적으로 SaveGameStateGameInfo::StartMatch() 를 사용한 후 딜레이를 두고 Super.StartMatch() [UTGame::StartMatch()] 를 호출한다는 점입니다. 디폴트로 bWaitingToStartMatch 는 참이고 bDelayedStart 는 거짓인 경우 GameInfo::StartMatch() 가 자동 호출됩니다. 그러나 이 기능이 게임에 맞지 않으면, SaveGameStateGameInfo::StartMatch() 를 호출해야 한다는 점 기억하십시오. SaveGameStateGameInfo::StartMatch() 의 콘텐츠를 옮길 수도 있습니다. 그게 거기 있는 주요한 이유는, SaveGameState 가 로드되기 전 SaveGameState 가 PlayerController 의 인스턴스를 요하기 때문입니다.

관련 토픽


내려받기