Sunday, October 27, 2013

AndEngine Game Structure

AndEngine을 이용해서 안드로이드 어플을 만든다는게 그리 쉬운 건 아닌거 같아.
가장 큰 문제는 레퍼런스가 부족하다는 거지. 유일하게 참조할 수 있는게 공식 홈페이지 인데, 내가 원하는 정보를 찾기도 그리 쉽지만은 않지. 물론, 유용한 블로그들도 있어. 하지만 이것도 그리 많지는 않아서 빠르게 정보를 찾아 문제점을 해결하고자 할때엔 아쉽기만 하다.

그래서, 안드로이드 앱을 만들면서 AndEngine을 사용하고자 하는 사람들에게 조금이나마 도움을 주고자. 관련글을 적어보기로 했어. 많은 내용이 아닐 수도 있고, 어떤 부분에 대해선 깊게 설명하지 못하는 수도 있겠지만 가능하면 이 글을 일읽는 사람이 AndEngine을 '시작함'에 있어서는 나처럼 시간 낭비하지 않고 쉽게 접근할 수 있길 바란다.
(번역 및 출판을 위해 작업중인 글 중 일부를 올리는 것입니다. 무단복제 하시면 나중에 뒷감당도 하셔야 해요)




아래는 다음과 같은 내용을 담고 있어.(이번 포스팅에서 어떠한 주제를 다루게 될 것인지 꼭 '기억'해 두고 나머지 긴 글을 읽자. 다루는 주제가 뭔지는 기억하고 가야 하지 않을까?)


  1. AndEngine에서 제공하는 액티비티의 라이프사이클
  2. AndEngine의 다양한 액티비티 타입
  3. AndEngine의 엔진 타입
  4. AndEngine의 해상도 정책(Resolution Policy)
  5. Object Factory : 게임 내 객체의 생성을 전담할 별도의 객체를 만들자
  6. Game Manager : 게임을 관리할 객체를 만들자
  7. 배경음악이나 효과음의 활용
  8. 다양한 텍스쳐의 활용
  9. AndEngine 폰트 리소스를 이용하는 방법
  10. Resource Manager : 리소스만 별도로 관리할 수 있도록 하자
  11. 게임 데이터의 저장과 로딩


AndEngine을 이용해서 게임 앱을 개발한다 할지라도, 그 시작은 Activity에서 시작해. 일반 안드로이드 앱과 동일하지. 다행스러운 건, 개발자들이 좀 더 편하고 수월하게 개발에 임할 수 있도록 다양한 Activity클래스들이 준비되어 있다는 거야.





AndEngine은 상황에 따라 확장 가능한 다양한 Game Activity를 제공한다

가장 기본적인 액티비티 클래스가 BaseGameActivity야.
그리고, 그 외에도 LayoutGameActivity, SimpleBaseGameActivity, SimpleLayoutGameActivity, SimpleAsyncGameActivity 클래스가 있지. 이름에서 짐작할 수 있는 것처럼 각각의 액티비티 클래스들은 그 나름대로의 용도가 따로 있어서 개발자의 요구에 따라 각각 다른 액티비티 클래스를 상속해서 액티비티를 만들어 가면 돼.

지금은 가장 기본적인 게임 액티비티인 BaseGameActivity에 대해 살펴보고, 다른 액티비티 클래스 들에 대해선 나중에 사용하게 될 때 각각의 특성을 보자구.





게임 액티비티의 라이프 사이클

안드로이드 앱 개발을 할 때도 액티비티의 라이프 사이클을 잘 알고 있어야 하는 것처럼 AndEngine을 이용할 때도 마찬가지야. 라이프 사이클 별로 개발자가 해줘야 할 몫이 있는 만큼 AndEngine의 라이프 사이클, 그와 연관되어 있는 메소드, 그리고 그 메소드 내부에서 처리해줘야 할 일들을 숙지하고 있어야 하지.

지금 이 순간, 특별히 관심을 줘야 하는 라이프 사이클 관련 메소드는 네 개야.

  • onCreateEngineOptions()
  • onCreateResources()
  • onCreateScene()
  • onPopulateScene()

이 메소드들은 (메소드 이름에서 짐작할 수 있겠지만) EngineOptions 객체를 생성하고, 게임에 사용할 리소스를 준비하고, Scene객체를 생성한 후에, 자식 엔티티를 갖는 Scene 객체를 populating하는 역할을 맡는 메소드들이야. 호출되는 순서도 나열된 순서와 같아.

우선 아래 보이는 코드처럼 ApplePieActivity라는 이름의 액티비티를 작성한 후에 에뮬레이터나 디바이스에 올려 테스트해보자. (문제 없이 실행되더라도 화면엔 아무것도 보이지 않는다. 액티비티의 라이프사이클을 확인하는 용도로만 작성되는 클래스이다)

앞서 언급된 네 개의 메소드에는 각각의 구체적인 사용법이 설명되어 있으니 더욱 유심히 쳐다보도록 하자.


public class ApplePieActivity extends BaseGameActivity {
 
 private static final int WIDTH = 800;
 private static final int HEIGHT = 480;
 
 private Camera mCamera;
 private Scene mScene;

  //
  //onCreateEngineOptions() 메소드에서는 Engine 객체를 생성할 때 적용할 다양한 옵션을 
  //지정해 줄 수 있습니다. 아래의 예제 코드처럼 wake lock 설정을 해 줄 수도 있고,
  //멀티터치 옵션이나 렌더링 옵션 등도 지정해 줄 수 있습니다.


 @Override
 public EngineOptions onCreateEngineOptions() {
  
  //먼저 우리가 원하는 화면 크기로 Camera 객체를 먼저 생성해 주고, 
  mCamera = new Camera(0, 0, WIDTH, HEIGHT);
  
  //다음으로 EngineOptions 객체를 생성합니다. EngineOptions의 생성자에 전달되는 네 개의 값은 아래와
  //같습니다.
  // 1.Full Screen 모드로 설정할 것인지를 boolean 값으로 지정해주고,
  // 2.화면을 세로로 혹은 가로로 나타낼 것인지,
  // 3.화면은 비율에 따라 혹은 해상도에 따라 (RatioResolutionPolicy or FillResolutionPolicy) 뿌리게 될 것인지
  // 4.미리 생성된 카메라 객체 

  EngineOptions engineOptions = new EngineOptions(true, 
              ScreenOrientation.LANDSCAPE_FIXED, 
              new FillResolutionPolicy(), 
              mCamera);
  
  //게임 내에서의 별다른 동작이 없다고 해서, 디바이스의 화면이 꺼져버리면 안되겠다 싶으면 아래처럼 
  //화면을 상시 켜놓으라고 해줄 수 있습니다.
  engineOptions.setWakeLockOptions(WakeLockOptions.SCREEN_ON);
  
  //이렇게 생성된 EngineOptions객체가 리턴되고, 이게 Engine 객체로 전달이 됩니다.
  return engineOptions;
 }

 
  //onCreateResources() 메소드는 텍스쳐나 사운드, 폰트 등 게임에 필요한 거의 모 리소스들을 로딩하는 곳입니다.
  //리소스들이 모두 로딩되면 맨 마지막에 onCreateResourcesFinished()를 호출하여 완료되었음을 알리면 됩니다.

 @Override
 public void onCreateResources(
   OnCreateResourcesCallback pOnCreateResourcesCallback)
   throws Exception {
  pOnCreateResourcesCallback.onCreateResourcesFinished();
 }

  // onCreateScene() 메소드는 이름에서 알 수 있듯 Scene 객체의 초기화를 담당하는 메소드입니다.
  //여기서 설정된 Scene 객체가 Engine 객체의 메인 씬(main scene)으로 세팅되며, 여기에선 보여주지 않지만
  //터치 리스너, 업데이트 리스너, 그 외에 Scene 과 관련된 다른 리스너들을 세팅해줄 수 있습니다.

 @Override
 public void onCreateScene(OnCreateSceneCallback pOnCreateSceneCallback)
   throws Exception {
  mScene = new Scene();
  pOnCreateSceneCallback.onCreateSceneFinished(mScene);
 }

  //onPopulateScene() 메소드는 Scene 객체의 생성과 Scene 에서 생성되어 져야하는 객체들의 생성을 분리하는 역할을 하게 됩니다
  //scene 객체의 생성은 onCreateScene에서, scene 의 child entity 들은 이 메소드에서 생성되어 연결되면 되겠습니다.
  // 엔진에 이미 전달되어 메인 씬으로 세팅되어진 그 scene 에 대한 (entity 추가)작업이 이루어지는 것입니다.

 @Override
 public void onPopulateScene(Scene pScene,
   OnPopulateSceneCallback pOnPopulateSceneCallback) throws Exception {
  pOnPopulateSceneCallback.onPopulateSceneFinished();
 }
}




AndEngine이 제공하는 Activity 타입들

BaseGameActivity 클래스 말고도 몇 개의 클래스가 더 제공되어서 게임 액티비티를 상황에 따라 좀 더 수월하게 작성할 수 있도록 돕고 있다.


  • LayoutGameActivity : 게임 내에서 Android SDK에서 제공하는 뷰들을 사용할 수가 있도록 해준다. 하지만, 일반적인 사용법은 게임에 광고를 삽입하기 위한 용도로 사용된다. 나중에 실제 예를 볼 기회가 있을 것이다.

  • SimpleBaseGameActivity, SimpleLayoutGameActivity : BaseGameActivity클래스를 상속받아 액티비티를 만들경우엔 라이프사이클과 관련된 메소드들을 재정의 해야만 하고, 경우에 따라선 콜백메소드들을 호출해 줬어야 하는데, 이 두 클래스는 그걸 하지않아도 된다. 좀 더 간단히 액티비티를 구현할 수 있도록 해준다랄까?

  • SimpleAsyncGameActivity : 이 클래스에는 onCreateEngineOptions() 메소드 외에 재정의 해줘야 할 메소드가 세 개가 더 있다. onCreateResourceAsync(), onCreateSceneAsync(), onPopulateSceneAsync(). 클래스의 이름을 보면서 예상했을 수도 있을텐데, 이 클래스는 각각의 Async 메소드들을 위한 로딩 바(loading bar)를 제공한다. 아래 코드를 보면 대충 짐작이 되리라 생각한다


@Override
public void onCreateResourceAsync(IProgressListener pProgressListener) throws Exception {
   //첫 번째 리소스를 로딩하고서
   pProgressListener.onProgressChanged(33);
   //두 번째 리소스를 로딩하면
   pProgressListener.onProgressChanged(66);
   //마지막 리소스를 로딩하면
   pProgressListener.onProgressChanged(100);
}





AndEngine의 Engine Type

게임을 프로그래밍하기 전에 우리가 만들 게임이 어느 정도의 일처리 능력(performance needs)을 필요로 할 지 가늠해 보는 게 좋다. 그 요구에 맞는 엔진 타입을 골라 사용하기 위해서라도 꼭 고민해봐야 한다. AndEngine에는 그러한 요구에 합당한, 장점을 갖는 몇 개의 엔진 타입이 포함되어 있기 때문이다.

이전에 작성했던 예제에서는 일반적으로 사용하는 엔진 타입(Engine)을 그대로 사용하였다. 아무것도 해주지 않았다고? 그렇다. 아무것도 해주지 않았다. 아래 코드 중 주석을 제외한 부분이 우리가 '있던 그대로 사용하던 코드'이다.

//onCreateEngine()메소드 내에서 우리가 원하는 엔진타입을 생성해서 리턴할 수 있다.
@Override
public Engine onCreateEngine(EngineOptions  pEngineOptions){
   return super.onCreateEngine(pEngineOptions);
   //super.onCreateEngine(pEngineOptions)가 호출되면 실제 super에서는
   //return new Engine(pEngineOptions); 
   //구문이 실행된다. Engine클래스 타입의 엔진이 생성되어 리턴된다는 뜻이다.
   
   //그러므로, 우린 어떤 타입의 엔진이 있는지 알고, 우리의 게임에 맞게 적절한
   //타입의 엔진을 생성한 뒤에 리턴해버리면 되겠다.
}


위 코드의 주석에 씌여 있는 것처럼, 자동으로 재정의 되는 코드를 사용하게 되면, 부모객체의 onCreateEngine()메소드가 호출되고 부모 객체에서는 (가장 일반적인 엔진 타입인) Engine 객체를 리턴해 버린다. 그러므로, 우리가 원하는 별도의 엔진 타입이 있다면 부모객체에게 Engine객체의 생성과 리턴을 맡길 게 아니라, 그냥 우리의 액티비티 객체에서 내가 원하는 엔진 객체를 생성한 다음에 리턴하도록 재정의 해주면 된다.

//onCreateEngine()메소드 내에서 우리가 원하는 엔진타입을 생성해서 리턴할 수 있다.
@Override
public Engine onCreateEngine(EngineOptions  pEngineOptions){
   return new LimitedFPSEngine(pEngineOptions);
}

위 코드에서는 LimitedFPSEngine이라는 이름의 엔진 타입을 생성하고 리턴했는데, AndEngine에서는 어떤 엔진 타입들을 지원하고 있을까? 아래 간단히 요약해 보았다.

 Engine 

초당 몇 프레임이 보여야하는지에 대한 제한이 전혀 없다. 말하자면 빠른 처리가 되는 디바이스에서는 초당 60프레임이 될 수도 있고, 그렇지 않은 디바이스에서는 20프레임이 될 수도 있다는 거지. 그래서, 일반적인 게임 개발에는 그다지 어울리지 않는다. 초당 처리되어야 하는 프레임과는 전혀 상관없는 게임이라면 가능할 수도 있겠다(근데, 그런 게임이 몇개나 될까) 특히, Physics 엔진(물리 엔진)을 사용하는 게임이라면 쥐약이 되겠다. 위에 언급한 제약이 별 의미 없는 아주 간단한 게임이라면, 아무런 추가 코드가 없이 그냥 사용하면 된다. 앞서 말한데로 그냥 사용하면 Engine타입을 사용하게 될테니까

 FixedStepEngine 

디바이스가 아무리 다양해도 일정한 속도로 게임 루프(loop)가 업데이트 되도록 하기 때문에 아주 유용한 엔진이 되겠다. 코드를 실행하는 디바이스의 성능보다는 시간이 얼마나 흘렀느냐에 따라 업데이트가 수행된다. 그래서 Engine 타입과는 다르게 생성할 때 EngineOptions 말고 정수형 값을 요구한다. 이 정수형 값이 초당 몇 회 수행되어야 할지를 지정하는 값이 된다. 그러니 아래의 코드는 초당 60회의 업데이트 메소드가 수행되는 코드가 되겠다

@Overrde  
public Engine onCreateEngine(EngineOptions pEngineOptions){  
   //create a fixed step engine updating  at 60 steps per second  
   return new FixedStepEngine(pEngineOptions, 60);
}


 LimitedFPSEngine 

이 엔진은 초당 프레임수를 제한할 수 있도록 해주는 엔진이다. 엔진이 (개발자가 주는 초당 프레임수를 기초로) 수행간격(interval)을 결정하는데,  엔진 내에서 결정된 FPS보다 주어진 FPS가 더 클 경우엔 그 초과한 시간만큼 쉬면서 기다리다 업데이트를 수행하는 방식이다. 그래서, 이 엔진의 생성자에는 EngineOptions 객체와 함께 정수형의 최대 FPS 값을  함께 받도록 되어 있다. 아래 코드를 보자.

@Override
public Engine onCreateEngine(EngineOptions pEngineOptions){  
   //create a limited FPS engine, which will run at a maximum of 60 FPS  
   return new LimitedFPSEngine(pEngineOptions, 60);
 }


 SingleSceneSplitScreenEngine, DoubleSceneSplitScreenEngine

 이 두 엔진 타입은 별도의 두 개의 카메라를 사용하는 게임을 만들 수 있도록 해주는 엔진이다. 싱글 플레이어용으로 하나의 씬(scene)에 그렇게 하거나, (하나의 디바이스에) 멀티 플레이어 용으로 두 개의 씬을 갖게도 할 수 있다. 미니맵을 사용하거나, 여러 시점을 제공하는 게임, 메뉴 시스템에도 사용할 수 있고... 다양한 활용이 가능하다. (이와 관련된 별도의 포스팅은 나중에 이루어질 것이다)

 Engine, FixedStepEngine, LimitedFPSEngine의 실제 구현 코드들은 GitHub를 통해 AndEngine의 소스코드를 내려받아 이클립스 프로젝트를 생성한 상태라면 쉽게 볼 수 있다. AndEngine의 API가 아주 부족한 상태라서 소스코드를 직접 봐야하는 경우가 많으니 미리 적응해 두는 것도 좋으리라.




AndEngine에서의 해상도 처리(Resolution Policy)

 안드로이드의 디바이스 종류가 워낙 다양하다보니, 우리가 만든 게임이 어떻게 디바이스에서 나타날 것인지에 대해서도 미리 알고 고민해봐야 할 필요가 있다. 이미 간단한 액티비티를 작성하면서 봤지만, AndEngine을 이용할 때 어떤 해상도를 갖게 할 것인지는 AndEngine의 라이프사이클 메소드 중 onCreateEngineOptions() 내에서 엔진 옵션 객체를 생성하면서 파라메터로 해상도 관련 값을 지정해주며 선택할 수가 있다. 아래는 FillResolutionPolicy클래스를 이용해서 EngineOptions객체를 생성한 예이다. EngineOptions engineOptions = new EngineOptions(true, ScreenOrientation.LANDSCAPE_FIXED, new FillResolutionPolicy(), camera); 이런 식으로 다양한 해상도 정책을 게임에 적용해 줄 수가 있다. 그럼 어떤 해상도 정책들이 존재하는 지를 알아야겠지? BaseResolutionPolicy클래스의 서브 클래스로 다음과 같은 클래스들이 있다. 클래스 이름에서 대충은 그 정책의 내용까지도 짐작할 수가 있다.
  • FillResolutionPolicy
  • FixedResolutionPolicy
  • RatioResolutionPolicy
  • RelativeResolutionPolicy


FillResolutionPolicy
어플리케이션이 화면에 가득차게 보이게 하고 싶을 때 일반적으로 사용하는 해상도 정책이다. 디바이스의 전체 화면을 모두 사용해 버리기 때문에 개발자가 원하던 모양과는 다르게 가로나 세로로 늘어져 보일 수도 있다. 800x480을 기본으로 디자인되었는데 실행되는 디바이스가 760x480 화면이라면? EngineOptions의 생성자에 파라메터로 FillResolutionPolicy의 디폴트 생성자로 생성되는 객체를 넘기기만 하면 된다.


FixedResolutionPolicy
디바이스의 화면 크기나 Camera가 보여주려는 화면 크기에 상관없이 고정된 화면 크기를 원한다면 사용할 수 있는 정책이다. 이것도 마찬가지로 EngineOptions에 new FixedResolutionPolicy(pWidth, pHeight) 호출로 생성된 객체를 파라메터로 전달하면 되는데, 이때 pWidth와 pHeight는 어플리케이션이 화면에서 차지할 가로와 세로 크기이다. 어플리케이션이 차지하는 부분보다 디바이스의 화면크기가 더 크다면, 남는 부분은 검게 나타날 것이다. 옆 그림은 이전에 만들었던 예제의 Camera가 사용하는 가로 세로 값을 그대로 FixedResolutionPolicy의 생성자에 넘겼을때 보이는 화면이다. 갤럭시 넥서스가 1280x720인 상태에서 FixedResolutionPolicy에 넘겨진 값이 800, 480이니 그림 처럼 가운데가 파랗고 주변이 검게 비워진 화면이 나타난 것이다.


RatioResolutionPolicy
어플리케이션이 차지할 화면이 늘어지거나 축소되거나 하는 일이 없이 어느 디바이스에서나 균일하게 나타나게 하고 싶을 때 선택할 수 있는 가장 좋은 해상도 정책이 RatioResolutionPolicy가 되겠다. 하지만, 화면 상하좌우 어느 부분에 검은 빈 공간이 생기는 걸 피할 수는 없을 것이다. 안드로이드 기기들이 워낙에 다양해서 그 모두에 들어맞게 하기는 힘드니까. 이 정책을 위한 객체 생성에는 가로와 세로의 비율(width/height)을 나타내는 float값이 전달될 수도 있고, 가로 세로 크기에 해당하는 값이 각각 전달될 수도 있다.
우측 첫번째 그림은 1f(가로 세로가 1대1 비율이라는 뜻이겠다), 두 번째 그림은  2f일때의 화면이다.







RelativeResolutionPolicy
RelativeResolutionPolicy는 생성자로 하나의 float값이나 혹은 두 개의 float값을 인자로 받게 되는데, 하나를 전달 받을 경우 그 값으로 가로와 세로의 크기를 scaling한다. 예를 들어 0.9f를 전달받았다고 한다면 가로 세로 모두 90% 크기로 화면을 사용하게 된다는 뜻이다. 1.2f라면 120%가 되어서 디바이스의 화면크기를 초과하게 되는데, 이렇게 디바이스의 실제 화면 크기를 초과해서 사용할때엔 앱이 종료되거나 화면에 제대로 표현되는지를 꼭 확인해야 한다. 갤럭시 넥서스의 경우 1.7f를 초과하게 되면 화면에 제대로 출력하지 못하는 듯 보인다.
우측 첫번째 그림은 파라메터로 0.9f 하나만 생성자에 전달했을 때의 화면이고. 두번째는 생성자에 1f와 0.5f 두개를 파라메터로 전달했을 때의 화면이다. 가로는 디바이스의 가로 크기와 동일하고 세로만 절반으로 줄었음을 볼 수 있다.











게임에 필요한 객체의 생성을 전담하는 클래스를 두자 : Factory패턴의 활용 

프로그래밍을 접해보신 분들은 Factory패턴에 대해 많이 들어보았을 것이다. 일반적인 팩토리 패턴에서 객체의 생성을 전담하는 별도의 클래스를 두는 것처럼, 게임에서의 팩토리 패턴에서도 마찬가지로 객체의 생성을 전담하는데, 게임인 만큼 게임 플레이에 사용되는 다양한 것들의 생성도 담당하여야 한다. 소리, 음악, 이미지들(텍스쳐), 폰트 관련한 객체의 생성 역시 이러한 패턴으로 작성할 수 있다. 아래 보이는 예시 코드는 그냥 일반적인 팩토리 패턴의 구현 모습을 보여줄 뿐이다. 실제 게임에서의 활용은 소리, 음악, 이미지들을 AndEngine에서 활용하는 방법을 본 후에 적용토록 해보자.


아래의 코드는 일반적인 작성법을 설명하기 위한 것일 뿐이지, 실제 개발할 때 이처럼 작성되지는 않는다. 실제 게임에 사용될 객체들을 이렇게 팩토리 클래스 내부에서 작성하는 건 없다고 봐야겠지.


public class ObjectFactory {
 
    // LargeObject 객체의 생성을 요청받아, ObjectFactory에서 직접 생성해서 리턴해주는 메소드.
    public static LargeObject createLargeObject(final int pX, final int pY){
        return new LargeObject(pX, pY);
    }
 
    // SmallObject 객체의 생성을 요청받아, ObjectFactory에서 직접 생성해서 리턴해주는 메소드
    public static SmallObject createSmallObject(final int pX, final int pY){
        return new SmallObject(pX, pY);
    }
 
    // 편의상 ObjectFactory클래스 내부에 선언된 BaseObject 클래스
    public static class BaseObject {
  
        /* 여기서의 mX나 mY 같은 인스턴스 변수가 뭘 의미하는지는 그리 중요하지 않다. 
         *  실제 게임 개발에서는  이 두 개의 변수처럼 색상, 크기, 좌표 등 스프라이트나 그 외의 
         * 엔티티 등의 속성을 보관하는 변수로 선언해 쓰면 되겠다. */
        private int mX;
        private int mY;
  
        // BaseObject 의 생성자
        BaseObject(final int pX, final int pY){
            this.mX = pX;
            this.mY = pY;
        }
  
        // BaseObject클래스의 내부클래스로 선언된 LargeObject 클래스
        public static class LargeObject extends BaseObject{

            // LargeObject의 생성자
            public LargeObject(int pX, int pY) {
                 super(pX, pY);
            }
   
        }
  
        // BaseObject클래스의 내부클래스로 선언된 SmallObject 클래스
        public static class SmallObject extends BaseObject{

            // SmallObject의 생성자
            public SmallObject(int pX, int pY) {
                super(pX, pY);
            }
        }
    }
}


코드를 보면 알겠지만, 팩토리 클래스의 이너클래스로 BaseObject클래스가, BaseObject클래스의 이너클래스로 다시 BaseObject클래스를 확장하는 LargeObject와 SmallObject클래스가 작성되었다.

관심있게 봐야할 부분은 ObjectFactory클래스의 메소드들인데,  팩토리 클래스에서 취급하는 객체들(여기서는 LargeObject와 SmallObject의 객체들)을 직접 생성해서 리턴해주는 메소드들이다. 이것처럼 팩토리는 자신이 담당하는 객체 타입의 생성을 전담하고 그 생성 요청을 받을 메소드를 선언하여 작성하면 된다.

실제 게임을 개발할 때를 가정한다면, SpriteFactory 클래스를 만들어서 이 클래스에서 일반적인 스프라이트는 물론, 버튼 스프라이트나 타일드 스프라이트(tiled sprites)까지 모든 스프라이트의 생성을 전담케 할 것이다. SpriteFactory.createSprite(), SpriteFactory.createButtonSprite(), SpriteFactory.createTiledSprite() 같은 메소드를 SpriteFactory클래스 내에 선언할 것이고, 이들 메소드의 매개변수를 통해서 Sprite나 ButtonSprite, TiledSprite의 생성에 필요한 기본적인 정보를 전달 받을 수 있도록만 해주면 된다.(mX, mY처럼)






별도의 관리자 객체를 두어 게임을 관리하자

게임에 사용되는 정보들은 아주 다양하다. 게임 플레이 중에 계속 업데이트 되어질 획득 포인트, 경험치, 체력과 마나 상태, 현재 진행중인 게임의 레벨 등. 이것들을 효율적으로 다루기 위해서는 관리를 전담하는 별도의 클래스를 두는 게 적절하다. 그래서, 아래에서는 그러한 관리자 클래스를 작성하여 활용하는 방법을 간단하게 살펴볼 것이다.
싱글턴 패턴으로 관리자 클래스를 작성하자.
게임이 실행된 후 게임을 관리할 객체는 하나만 있으면 되므로, 이 클래스는 싱글턴 패턴으로 작성하는 게 적절하다. 패턴의 이름처럼 이 클래스의 객체는 앱이 동작되는 동안 오직 하나 뿐이어야 하며, 앱 동작 중 언제든 그 객체에 대한 참조가 가능해야 한다.

위에 언급한 고려 사항들을 생각하며 아래처럼 GameManager클래스를 작성해 보았다.


public class GameManager {

    // 이 클래스는 싱글턴이므로, 정적 변수로 자기 자신의 타입을 선언했다.
    // 따라서, 이 클래스의 인스턴스는 어플리케이션이 실행되는 동안 오직 하나의 
    // 인스턴스만 존재한다.
     private static GameManager INSTANCE;
 
    private static final int INITIAL_SCORE = 0;
    private static final int INITIAL_BIRD_COUNT = 3;
    private static final int INITIAL_ENEMY_COUNT = 5;
 
    // 게임 매니저 인스턴스는 게임과  관련된 특정 데이터들을 지속적으로 
    // 살피고, 추적할 수가 있다. 현재의 스코어, 남은 총알의 갯수, 아직 남아있는
    // 적군의 수 등이 이런 정보가 될 것이다 
    private int mCurrentScore;
    private int mBirdCount;
    private int mEnemyCount;
 
    // 아래처럼 접근 제한을 default로 두는 대신 아무것도 하지 않게 하거나
    // 외부에서는 절대 생성호출을 하지 못하도록 접근 제한을 private으로 두어 처리할 수도 있다.
    GameManager(){
    }
 
   /* 싱글턴 클래스의 인스턴스는 외부에서 생성할 수도 없고, 유일하게 생성된 인스턴스에 
     * 접근할 수 있는 방법도 없다. 따라서, 생성된 인스턴스에 접근할 수 있도록 static성분의
     * 메소드를 제공해줘야 한다. 이렇게 하면 클래스 이름만으로도 어디서든 '생성된 인스턴스
     * 에 접근하고 싶습니다'라는 요청을 보낼 수 있다. 아래 메소드에서는 인스턴스가 생성되지
     * 않았을 경우 다시 한번 생성자를 호출해서 생성하도록 하고 있다 
     */
 
    public static GameManager getInstance(){
        if(INSTANCE == null){
            INSTANCE = new GameManager();
        }
        return INSTANCE;
    }
 
    // 현재 스코어에 대한 접근
    public int getCurrentScore(){
        return this.mCurrentScore;
    }
 
    // 현재 남아있는 새의 숫자
    public int getBirdCount(){
        return this.mBirdCount;
    }

    // 적군의 수
    public int getEnemyCount(){
        return this.mEnemyCount;
    }
 
    // 적을 쏘아 죽이면 스코어를 증가시키고
    public void incrementScore(int pIncrementBy){
        mCurrentScore += pIncrementBy;
    }
 
    // 새를 던지면, 남아있는 새의 숫자를 줄이고
    public void decrementBirdCount(){
        mBirdCount -= 1;
    }
 
    // 적군을 죽이면 남아있는 적군의 수도 줄여야겠다
    public void decrementEnemyCount(){
        mEnemyCount -= 1;
    }
 
    // 리셋하면, 정해진 값으로 초기화 해야지
    public void resetGame(){
        this.mCurrentScore = GameManager.INITIAL_SCORE;
        this.mBirdCount = GameManager.INITIAL_BIRD_COUNT;
        this.mEnemyCount = GameManager.INITIAL_ENEMY_COUNT;
    }
}



 

효과음과 배경음악

 게임에 빠질 수 없는 게 효과음과 배경음악이다. 여기서는 그것들을 어떻게 로드(load)하고 게임에 맞게 사용할 것인지에 집중한다. 효과음과 배경음악을 위해서 준비된 객체 타입이 이미 준비되어 있다. 총을 쏘거나 폭발하거나, 부딪히거나, 발걸음 소리 같은 짧게 사용되는 것들은 Sound 객체를 사용하면되고, 배경음악 처럼 길게 플레이되고 반복 사용되는 것들은 Music 객체를 사용하면 되겠다.

1. 이것들을 사용하기 위해선 먼저 Engine객체에게 Sound와 Music객체를 사용할 것임을 알려야 하는데, EngineOptions객체를 이용해서 알리면 된다. onCreateEngineOptions() 메소드 내에서, EngineOptions객체를 생성한 코드 뒤에(EngineOptions객체를 리턴하기 전에) 다음과 같은 코드를 추가하자.

engineOptions.getAudioOptions().setNeedsMusic(true);
engineOptions.getAudioOptions().setNeedsSound(true);


2. 다음으로, 효과음과 배경음악 파일들이 어디에 있는지 지정하고, 그것들을 로드(load)한다. Sound와 Music 객체는 리소스이므로 onCreateResources() 메소드에 아래의 코드를 작성하면 된다

    /* 필요한 오디오 파일을 찾을 수 있도록 SoundFactory와 MusicFactory의 기본 경로를 지정해 준다 */
    SoundFactory.setAssetBasePath("sfx/");
    MusicFactory.setAssetBasePath("sfx/");
 
    //효과음을  로드한다. 로드하는 오디오 객체가 어떤 타입이냐에 따라 getSoundManager()나 getMusicManager()를 첫번째 인자로, Context객체(여기서는 BaseGameActivity )를 두번째로, 문자열로 된 오디오 파일명을 세번째 인자로 넘겨주면 된다
    try{
        Sound mSound = SoundFactory.createSoundFromAsset(getSoundManager(), this, "sound.mp3");
    }catch(IOException e){
        e.printStackTrace();
    }

    //음악을 로드한다
    try{
        Music mMusic = MusicFactory.createMusicFromAsset(getMusicManager(), this, "music.mp3");
    }catch(IOException e){
        e.printStackTrace();
    }
   

3. Sound객체의 경우 SoundManager로 로드되면 언제든 play() 메소드를 호출해서 재생하면된다. play()메소드 호출은 onCreateResources()에서 리소스들을 모두 로드 한 후에 이루어져야 함을 기억하자.

    mSound.play();

4. Music은 조금 다르다. Music 객체가 게임 내내 반복 재생되어야 하는 경우(이런 경우가 대부분이다), 액티비티의 라이프 사이클 메소드 내에서 play()나 pause()를 처리해준다. 전화가 오거나 팝업창이 뜰 경우, 여러분이 만든 게임은 동작을 멈추고 전화를 끊으면 다시 시작해야 할 것이다. 이러한 상황에서 게임을 즐기던 사용자가 불편하지 않게, 자연스럽게 음악을 중지하거나 다시 시작하게 해줘야 하기 때문에 이와같은 처리를 하게 되는 것이다.
(이전에 살펴보았던 AndEngine의 라이프 사이클 메소드들을 다시한번 보는 게 도움이 될 듯 하다.)

    //액티비티의 라이프 사이클 메소드 중 onResumeGame()에서 재생되어야 한다
    @Override
    public synchronized void onResumeGame(){
        if(mMusic !=null && !mMusic.isPlaying()){
            mMusic.play();
        }
        super.onResumeGame();
    }

    //onPauseGame()에서 재생을 멈춰야 한다
    @Override
    public synchronized void onPauseGame(){
        if(mMusic!=null && mMusic.isPlaying()){
            mMusic.pause();
        }
        super.onPauseGame();
    }


Music , Sound 클래스 자세히 보기


  Music
  • seekTo(pMilliseconds) : 어느 부분에서 시작해야 할지를 지정할 수 있다. mMusic.getMediaPlayer().getDuration()으로 전체 재생시간을 1/1000 초 단위(milisecond)로 알 수 있으므로, 이와 함께 사용하면 유용할 것이다.
  • setLooping(pBoolean) : 반복 재생할 것인지 아닌지를 지정할 수도 있다. setLooping(true)로 지정되면, 어플리케이션이 종료하거나 setLooping(false)가 호출되기 전까지는 계속 반복한다.
  • setOnCompletionListener : 사운드 트랙이 끝나길 기다렸다가 개발자가 원하는 행동을 취할 수 있도록 해 준다. Music 객체에 OnCompletionListener를 붙여주면 된다.
        mMusic.setOnCompletionListener(new OnCompletionListener(){
            /* Music 객체의 끝에 도달하면 이벤트가 발생하고, 아래의 onCompletion() 메소드가 호출된다 */
            @Override
            public void oCompletion(MediaPlayer mp){
                //음악이 끝나면 수행해야하는 어떤 일을 여기에 작성해주면 된다.
            }
        });
    
  • setVolume : setVolumne(pLeftVolumn, pRightVolumne)의 형태로 사용되는데, 좌우 볼륨을 개별적으로 조절할 수 있다. 0.0f가 음소거, 1.0f가 최대 볼륨이 되겠다
Sound
  • setLooping : Music 클래스의 setLooping()과 동일하며, 다른게 있다면 mSound.setLoopCount(pLoopCount)로 몇 번이나 반복할 것인지를 지정해줄 수 있다는 게 다르다(pLoopCount는 당연히 정수형이다)
  • setRate : 재생 속도를 지정해 줄 수 있다. 기본 재생 속도가 1.0f 이므로 이것보다 적은 수이면 느리게, 크면 빠르게 재생된다. 하나 주의할 것이, Android API 문서에보면 재생 속도는 0.5f에서 2.0f 까지로 한정한다. 그 범위 밖이면 재생시에 에러가 발생할 수 있다.
  • setVolumn : Music 클래스의 내용을 보면 된다.
효과음이나 배경음악을 직접 제작하거나 손볼 준비가 되어 있지 않은 사람들을 위해 무료로 사용할 수 있는 리소스가 풍부한 사이트를 소개한다. 생각보다 유용한 오디오 파일들이 많으니 꼭 가서 살펴보길 바란다. http://www.soundjay.com





 텍스쳐를 활용하자 


 게임 내에서 텍스쳐 처리는 개발자에겐 주요한 골치거리 중 하나이다. 여기서는 텍스쳐 처리를 위한 기본 지식은 물론, 어떻게 하면 성능에 지장을 주지 않는 범위 안에서 활용할 수 있는지, 텍스쳐가 우리가 원하는데로 나타나지 않아서 생기는 문제들, 혹은 너무 과도하게 사용해서 발생하는 문제들은 어떻게 어떻게 피할 수 있을런지 등을 살펴볼 것이다.
시작하기에 앞서 텍스쳐를 위한 이미지들이 필요하다. 필요한 파일들을 다운로드 하든, 직접 만들던지 해서 assets/gfx 폴더에 옮기자


AndEngine에서의 Texture 처리 방법

1. BitmapTextureAtlasTextureRegionFactory에 사용할 이미지 파일들이 어디있는지(assets/gfx) 알려준다. (기본적으로 이 클래스는 assets/ 폴더를 알고 있기 때문에 추가적인 하위 디렉토리( gfx/ )만 알려주면 된다. 패스를 지정해주지 않는다면 각각의 그래픽 리소스들은 gfx/anyfile.png 처럼 접근할 수 있다.)
BitmapTextureAtlasTextureRegionFactory.setAssetBasePath("gfx/");

2. BitmapTextureAtlas를 생성한다. TextureAtlas는 여러 종류의 텍스쳐가 올려지는 큰 도화지 정도로 생각하면 되겠다. 이 도화지를 어느 정도의 크기로 준비할 것인지 정해서 생성하면 된다.
BitmapTextureAtlas mBitmapTextureAtlas = new BitmapTextureAtlas(mEngine.getTextureManager(), 120, 120);

 3. BitmapTextureAtlas가 준비되었으니, 실제 이 이미지를 객체 형태로 사용할 수 있도록 해주는 ITextureRegion객체도 생성할 수 있고, 그것들을 BitmapTextureAtlas에 위치시킬 수도 있다. 이런 모든 걸 하기 위해 필요한 객체가 앞에서 본 BitmapTextureAtlasRegionFactory객체이다. 이 객체가 ITextureRegion객체와 이미지 파일을 바인딩(binding)하게도 해주고, BitmapTextureAtlas에 ITextureRegion객체를 위치시킬 지점을 정할 수도 있게도 해준다.
/* create rectangle one at position (10, 10) on the mBitmapTextureAtlas */

ITextureRegion mRectangleOne = BitmapTextureAtlasRegionFactory.createFromAsset(mBitmapTextureAtlas, this, "rectangle_one.png", 10, 10);

이 외에 다른 이미지도 사용해야 한다면 위 코드와 마찬가지로 해주면 된다. 위치(10, 10)만 이미지 크기를 고려해서 지정해주면 되겠다.

4. 마지막으로 ITextureRegion객체를 메모리에 올린다.
mBitmapTextureAtlas.load();



좀 더 알아둬야 할 내용들

AndEngine을 이용해 개발할때, 텍스쳐를 만들어 사용하기 위해 알아야 할 두 개의 컴포넌트가 있으니 그게 바로 BitmapTextureAtlas와 ITextureRegion 객체이다.

BitmapTextureAtlas는 작은 색종이들을 붙일 수 있는 큰 도화지라 생각하면 되고, 이 도화지에 붙일 색종이들을 ITextureRegion으로 생각하자. ITextureRegion이 가리키는 메모리 상의 특정 텍스쳐는 BitmapTextureAtlas의 한 지점(x, y)에 부착되게 된다. 부착할 색종이의 크기와 색종이 간의 간격(texture atlas source spacing)에 신경 쓰지 않으면 겹칠 수도 있음(texture bleeding)을 기억하자. 겹치게 놓으면 나중에 sprite에 적용할 때에도 겹쳐진 그대로 보이게 된다.

기억할 점! Texture Atalas는 그 안에 붙일 ITextureRegion보다 과도하게 큰 크기로 만들어두지 않도록 하자. 그리고, 1024x1024크기 이상의 atlas를 생성하는 건 피하라. 요샌 디바이스의 성능이 많이 좋기도 해서 2048x2048도 가능할 때가 있으나 이건 디바이스마다 확인해줘야한다. (갤럭시 넥서스의 경우 2048까지는 가능하더라. 그 이상이면 제대로 화면에 나타나지 않는다). 그 이상의 도화지가 필요한 경우도 꼭 있을 것이다. 이럴 경우엔 Background Stitching 을 이용하면 되지만 여기서 설명할 부분은 아니다. 차후 포스팅되는 자료에 설명할 것이다.  


위에서 살펴본 것들 말고 더 있다

BuildableBitmapTextureAtlas

이 객체 타입은 손수 위치를 잡아줘야하는 불편함(?)이 없이 texture atlas에 ITextureRegion 객체를 붙일 수 있게 해준다. 말 그대로 자동으로 altas에 위치시키는 것이다. 위에서 잠깐 Texture Bleeding에 대해 언급했는데, 그러한 상황을 미연에 방지할 수 있는 방법이기도 하다. 사용하는 방법이 살짝 다르니 아래 코드를 보며 작성해 보자.


    //이전에 사용했던 BitmapTextureAtlas와 생성시 필요한 파라메터들은 동일하다 
    BuildableBitmapTextureAtlas mBuildableBitmapTextureAtlas = new BuildableTextureAtlas(mEngine.getTextureManager(), 120, 120);

    // 예로, 세 개의 ITextureRegion 객체를 생성해보자. 이전과 다른 점을 기억하자. 생성시에 x,y 위치 값이 필요없다 
    ITextureRegion mFirstResion = BitmapTextureAtlasTextureRegionFactory.createFromAsset(mBuildableBitmapTextureAtlas, this, "first.png");
    ITextureRegion mFirstResion = BitmapTextureAtlasTextureRegionFactory.createFromAsset(mBuildableBitmapTextureAtlas, this, "second.png");
    ITextureRegion mFirstResion = BitmapTextureAtlasTextureRegionFactory.createFromAsset(mBuildableBitmapTextureAtlas, this, "third.png");

    // try/catch 구문 내에서 mBuildableBitmapTextureAtlas를 빌드한다. 여기서 사용되는 BlackPawnTextureAtlasBuilder에 주어지는
    //세 개의 파라메터는 ( border spaceing, source spacing, source padding ) 값이다. 
    // 혹시나 겹치게 되면 세 번째 파라메터 값을 증가시켜보라.

    try{
        mBuildableBitmapTextureAtlas.build(new BlackPawnTextureAtlasBuilder&ltIBitmapTextureAtlasSource, BitmapTextureAtlas&gt(0,1,1));
    }catch(TextureAtlasBuilderException e){
        e.printStackTrace();
    }

    //atlas가 빌드되면, 로드해주면 된다.
    mBuildableBitmapTextureAtlas.load();


TiledTextureRegion

TiledTextureRegion은 일반 texture region과 동일하지만, 하나의 이미지 파일을 넘겨주면 그걸로 여러개의 sprite sheet를 뽑아낸다는 점이 다르다. 넘겨준 이미지 파일을 어떻게 쪼갤 것인지 열과 행(column, row) 정보만 주면 자동으로 조각내어 분리시킨다는 의미이다. 그 정보들을 TiledTextureRegion 객체가 쥐고 있는 것이고, 나중에 애니메이션을 갖는 스프라이트로 만들수가 있다.

    // sprite_sheet.png 이미지를 11조각 낸다. 가로가 110픽셀짜리 이미지라면, 10픽셀짜리 11조각으로.
    // Animation 부분은 다음에 실제 예를 통해 볼 기회가 있을 것이다.
    TiledTextureRegion mTiledTextureRegion =
             BitmapTextureAtlasTextureRegionFactory.createTiledFromAsset(mBitmapTextureAtlas, context, "sprite_sheet.png", 11, 1);



Compressed Textures

압축 형태의 텍스쳐(PVR, ETC1)도 지원한다는 것만 알고 넘어가자.


 

텍스쳐에 옵션 설정하기

이제 텍스쳐 옵션에 대해 알아볼 차례이다. 여러분이 만드는 게임의 퀄리티나 성능과도 직결된 이야기니 꼭 기억해 두자.

AndEngine의 기본 텍스쳐 옵션은 다음과 같다.

  • Nearest : 기본값으로 텍스쳐 아틀라스에 적용되는 옵션으로 가장 빠른 수행 능력을 보인다. 하지만, 퀄리티 면에선 가장 좋지 않다. 픽셀의 가장 가까운 텍셀(텍스쳐의 가장 작은 단위의 엘리먼트)의 색깔을 취해서 사용하는 방식입니다.
  • Bilinear : 두 번째 주요한 텍스쳐 필터링 옵션으로 성능면에서는 타격을 좀 입지만 스프라이트의 크기가 변할 때의 퀄리티는 더 낫다. Bilinear 필터링 옵션은 화면에 표시될 이미지가 좀 더 부드럽게 표현되게 하기 위해 픽셀과 근접한 상하좌우 네 군데의 텍셀(texel) 값들을 이용한다.

두 옵션만 비교한다면, bilinear가 계단 현상(이미지 경계부분이 계단처럼 표현되는 것)도 없고, 색도 부드럽게 표시된다. nearest는 계단 현상도 보이고 색도 부드럽지 못하고...


이 외에도 다음과 같은 옵션들이 있다.

Repeating : repeating 텍스쳐 옵션은 텍스쳐를 반복해서 붙이는 옵션이다. 스프라이트의 크기보다 ITextureRegion의 크기가 크다고 가정해서 반복하게 되는 것이다. 주로 사용되는 예가 게임 내에서의 백그라운드인데, 그 중에서도 지표면을 표현할 때이다. 캐릭터들이 움직이는 바닥을 표현하는데 수많은 별도의 스프라이트를 사용하기보다는 작은 텍스쳐를 이용해서 반복해서 가득 채우는게 더 낫지 않겠나?

Repeating 텍스쳐 옵션을 생성하는 예제 코드를 보자.


    // 반복할 텍스쳐는 2의 배수의 크기로 사용하자
    BuildableBitmapTextureAtlas texture = new BuildableBitmapTextureAtlas(engine.getTextureManager(), 32, 32, TextureOptions.REPEATING_BILINEAR);
    
    //Texture Region을 생성한다
    mSquareTextureRegion = BitmapTextureAtlasTextureRegionFactory.createFromAsset(texture, context, "square.png");

    try{
        //반복할 텍스쳐는 padding 값을 갖지 않게 해줘야 하겠지?
        texture.build(new BlackPawnTextureAtlasBuilder<IBitmaptextureatlassource, Bitmaptextureatlas>(0,0,0);
        texture.load();
    }catch(TextureAtlasBuilderException e){
        Debug.e(e);
    }


* Repeating 텍스쳐를 만들때는 다음의 사항을 꼭 기억하자.

  • 주석에서도 말했지만, 반복할 텍스쳐는 꼭 2의 배수의 크기를 이용해야 한다.
  • Buildable Texture Atlas를 이용할 경우, Padding이나 Spacing 값을 주지 말자


위의 코드에서 Repeating Texture를 생성했으니, 이제 어떻게 사용하는 지도 봐야하겠지?


    //Texture Region의 크기를 키운다. 이래야 반복될 텍스쳐가 그 크기로 확장될 것이다
    ResourceManager.getInstance().mSquareTextureRegion.setTextureSize(800, 480);

    //이제 스프라이트를 생성한다. 이 스프라이트는 (반복해 그려지면서) 전체 화면에 펼쳐지도록 해줘야 한다.
    //여기서 기억해야할 것은 스프라이트를 반복해서 붙이다가도 texture region의 가로/세로 크기를 벗어나면, 
    //벗어난 영역에는 아무런 텍스쳐도 적용되지 않는다는 것이다. 우리가 정한 texture region의 크기가 800, 480이라서 
    //이 예제에서는 가로/세로 32pixel의 텍스쳐는 정확히 texture region을 가득 채우지만, 정확히 나눠 떨어지지 않는다면
    //아무런 텍스쳐도 입혀지지 않는 부분이 새카맣게 표현될 수도 있다는 것이다. 
    Sprite sprite = new Sprite(0, 0, 800, 480, ResourceManager.getInstance().mSquareTextureRegion, mEngine.getVertexBufferObjectManager());


Pre-multiplay alpha : 이 옵션은 각각의 RGB 값에 Alpha채널의 값을 곱해 놓고, 실제 배경색과 합성할 때 원래 간직하던 색상 값이 아닌  그 곱해놓은 값을 이용하는 방식입니다(더 자세한 것은 구글링을 해보세요). 이렇게 하는 이유는 색의 손실이 없이 투명도를 적용하게 해주지만, 스프라이트의 알파 값을  수정해버리면 원치 않는 효과가 나올 수도 있고, 알파 값을 0인 경우(투명상태)에도 제대로 투명하게 나타나지 않는다는 단점이 있다.



텍스쳐의 포맷도 알아두자

텍스쳐의 포맷은 텍스쳐 옵션과 마찬가지로 텍스쳐를 사용할 용도에 맞게 선택하면 되는데, 오히려 텍스쳐 옵션보다도 성능이나 이미지의 퀄리티에 영향을 주게 되므로 잘 판단해서 선택 사용하자.


  • RGBA_8888 : Red, Green, Blue, Alpha에 대한 정보가 각각 8-bit 씩 할당된다. 총 32-bit 텍스쳐 포맷이다. 정보량이 많으니 가장 처리가 느리겠다.
  • RGBA_4444 : RGBA_8888의 딱 절반 정보량을 갖을 거 같다는 느낌이 오지 않는가? 4-bit 씩 정보가 할당되는 포맷이다. 
  • RGB_565 : 이것도 뒤에 붙은 숫자만 보면, 16-bit 정볼르 갖는 텍스쳐 포맷인걸 짐작할 수 있다. 하지만, 알파 채널에 대한 정보가 없다. 알파 채널에 대한 정보가 없다보니 사용 범위가 제한될테고,  전체 화면을 채우는 백그라운드를 표현할 때 사용하면 좋겠다.
  • A_8 :  RGB에 대한 정보(색상정보)가 없이 알파채널에 대한 정보만 8-bit 가진다. 그래서, 별도로 색상을 가지고 있는 스프라이트의 Alpha Mask(overlay)로 사용된다. 시간의 흐름에 따라 투명도를 조절하면서 fade-in / fade-out 효과를 표현할 때 사용된다는 뜻이다.


주요한 스프라이트에는 RGBA_8888을, 백그라운드를 표현할 때는 RGB_565를, 그리 중요하지 않은 스프라이트를 표현할 때는 RGBA_4444를 사용하면 되겠다.




AndEngine에서 폰트 리소스를 사용하는 방법

미리 설치된 폰트를 이용하는 방법 / 사용자가 추가 시킨 asset을 이용하는 방법 / 앞의 두 리소스들에 대해 stroke를 적용하는 방법을 살펴보려 한다.


  • 기기에 사전 설정된 폰트를 사용하기 위해서는 create() 메소드를 이용하면 된다

  •     Font mFont = FontFactory.create(mEngine.getFontManager(), mEngine.getTextureManager(), 256, 256,
                                                            Typeface.create(Typeface.DEFAULT, Typeface.NORMAL), 32f, true, 
                                                            org.andengine.util.adt.color.Color.WHITE_ABGR_PACKED_INT);
        mFont.load();
    


  • 사용자(개발자)에 구미에 맞는 폰트를 별도로 사용하려면 createFromAsset() 메소드를 사용한다
    (폰트 파일을 asset 폴더에 옮겨놓아야 겠지)

  •     
        Font mFont = FontFactory.createFromAsset(mEngine.getFontManager(), mEngine.getTextureManager(), 256, 256,
                                       this.getAssets(), "Arial.ttf", 32f, true, org.andengine.util.adt.color.Color.WHITE_ABGR_PACKED_INT);
        mFont.load();
    


  • Stroke를 적용한 폰트는 createStroke()와 createStrokeFromAsset()을 사용하면 된다.

  •     
        BitmapTextureAtlas mFontTexture = new BitmapTextureAtlas(mEngne.getTextureManager(), 256, 256, TextureOptions.BILINEAR);
    
        Font mFont = FontFactory.createStroke(mEngine.getFontManager(), mFontTexture, 
                                                            Typeface.create(Typeface.DEFAULT, Typeface.BOLD), 32f, true, 
                                                            org.andengine.util.adt.color.Color.WHITE_ABGR_PACKED_INT, 3, 
                                                            org.andengine.util.adt.color.Color.BLACK_ABGR_PACKED_INT);
        mFont.load();
    


위에 언급된 방법 중에서 여러분의 선택에 따라 골라 Font 객체를 생성한 후에 사용하면 된다. 세 가지 방법 모두 생성하는 방법이 다르지만, 텍스쳐의 width와 height를 정의하고 있는 건 동일하다. 모두 가로 세로 256 픽셀의 텍스쳐를 준비하도록 createXXXXX() 메소드에 직접 전달하거나 BitmapTextureAtlas 객체를 이용해서 간접적으로라도 전달하고 있지 않은가? 

안타깝게도 현재는 자동으로 텍스쳐의 크기를 지정하도록 하는 방법이 없다. 실행 타임에, 언어별로, 텍스트 사이즈별로, 스트로크 유무에 따라 혹은 폰트 스타일에 따라 자동으로 텍스쳐를 준비할 수가 없다는 뜻이다. 그러니 텍스쳐의 크기를 키우거나 줄이거나 하면서 적절한 값을 찾아야 하고, 보통은 폰트의 크기에 따라 텍스쳐의 크기가 크게 영향을 받으므로 32보다 큰 크기의 폰트를 사용한다면 텍스쳐를 좀 더 크게 준비해야 할 것이다.
 이 부분은 영문을 사용할 때에만 적용되는 이야기다. 한글 폰트는 그 폰트가 표현할 수 있는 글자수가 영문에 비해 대단히 많다. 그래서, 한글을 표현할때는 훨씬 큰 텍스쳐가 준비되어야 했던 걸로 기억한다. 그것마저도 제대로 안되었던 것 같고...이 부분은 기억이 확실치를 않아서 확인 후 수정하거나 하겠다

세 메소드들을  개발하는 사람의 시각에서 비교해 정리해보면,

  • create()은 자세한 커스터마이징이 힘들다. 사용할 폰트의 종류와 스타일이 한정되어 있다.
  • createFromAsset()은 우리에게 필요한 폰트를 asset 폴더에 옮겨 복사한 후에 사용할 수 있으므로, 좀 더 나은, 독특한 표현이 가능하겠다.
  • createStroke()/createStrokeFromAsset()은 사용할 폰트에 stroke를 적용함으로써 color는 물론, 두께와 글자의 테두리까지를 조절할 수 있다.

몇 가지 더

폰트가 정해지고 Text 객체를 이용해서(아직 이걸 수행하는 코드를 보여주진 않았지만) 화면에 문자열들을 출력할텐데, 화면에 실제 출력되는 문자들을 미리 로드해두면 좋지 않을까?

현재의 AndEngine은  새로운 글자가 그려져야 할 때 Garbage Collector를 호출해버린다. 자주 사용되는 글자들에 대해선 Garbage Collector로 소거시켜버릴 필요가 없지 않은가? 그렇다면, 화면을 로딩하는 시점 정도에서 다음과 같이 처리해 주자

    mFont.prepareLetters("abcdefghijklmnopqrstuvwxyz", toCharArray());

또 하나 중요한 클래스를 소개하자면, FontUtils 클래스인데...
Text 객체의 화면상에서의 너비(width) 값을 알 수 있게 해준다. meatureText(pFont, pText) 메소드를 이용해서 그리할 수 있는데, 문자열이 동적으로 수정되거나 할 때 유용하다.




Resource Manager의 작성과 활용

리소스를 관리하는 매니저 클래스를 준비하면, 보다 쉽게 텍스쳐나 사운드 폰트 등의 리소스를 로드해서 사용할 수 있지 않을까?

이전에 만들었던 매니저 클래스 처럼( 기억하는가?) 리소스 매니저도 싱글턴으로 작성하려 한다.
이 리소스 매니저 클래스의 목적은 '리소스 객체를 저장하고, 리소스를 로드하거나 혹은 언로드(unload)하기' 위한 것이다.  다음의 코드들을 보며 자세하게 보자.


public class ResourceManager {

    // ResourceManager의 인스턴스를 위해 선언해 줍니다. 싱글턴으로 작성하기로 했으니 당연히 static이어야 하겠죠?
    private static ResourceManager INSTANCE;
 
    //아래에 선언된 네 개의 인스턴스 변수들은 public으로 선언해서, 
    //스프라이트나 Text 객체를 만들때도 쉽게 접근할 수 있게 했고, 
    // 오디오 파일을 재생할 때도 손쉽게 했습니다.
    public ITextureRegion mGameBackgroundTextureRegion;
    public ITextureRegion mMenuBackgroundTextureRegion;
 
    public Sound mSound;
    public Font mFont;

    ResourceManager(){
        // 생성자에서는 아무것도 하지 않습니다.
    }

    // 단 하나 생성되는 ResourceManager 인스턴스에 접근하려면 이 메소드로
    // 레퍼런스를 받아가야 합니다.
    public synchronized static ResourceManager getInstance(){
        if(INSTANCE == null){
            INSTANCE = new ResourceManager();
        }
        return INSTANCE;
    }

    // 게임 내의 각 씬(scene)은 loadTextures()메소드와 unloadTextures() 메소드를
    // 모두 가지고 있어야 합니다. 그래서, 한 씬에서 다른 씬으로 넘어갈 때, 처음 씬에서는
    // unload 메소드를, 다음 씬에서는 load 메소드를 호출해서 준비해야 겠죠.
    public synchronized void loadGameTextures(Engine pEngine, Context pContext){
        // 게임 asset 폴더를 "assets/gfx/game/" 로 지정해 줍니다.
        BitmapTextureAtlasTextureRegionFactory.setAssetBasePath("gfx/game/");
  
        BuildableBitmapTextureAtlas mBitmapTextureAtlas = new BuildableBitmapTextureAtlas(pEngine.getTextureManager(), 800, 480);

        mGameBackgroundTextureRegion = BitmapTextureAtlasTextureRegionFactory.createFromAsset(mBitmapTextureAtlas, pContext, "game_background.png");
  
        try {
            mBitmapTextureAtlas.build(new BlackPawnTextureAtlasBuilder<IBitmaptextureatlassource, Bitmaptextureatlas>(0, 1, 1));
            mBitmapTextureAtlas.load();
        } catch (TextureAtlasBuilderException e) {
            Debug.e(e);
        }
  
    }
 
    // 게임이 완전히 다른 모드로 돌아서면 이전의 텍스쳐들은 필요 없겠죠?
    // 그럴 경우를 대비해서 모든 텍스쳐를 unload할 수 있도록 도 해줘야 겠네요
    public synchronized void unloadGameTextures(){
        // 메모리 올라가 있는 텍스쳐들을 모두 지워버리기 위해 다음과 같은 과정이...
        BuildableBitmapTextureAtlas mBitmapTextureAtlas = (BuildableBitmapTextureAtlas) mGameBackgroundTextureRegion.getTexture();
        mBitmapTextureAtlas.unload();
  
        // 기타 등등의 텍스쳐들에 대해서도 같은 작업(unload)을 해주고...
  
        // 모든 텍스쳐들이 unload 되었으면, 가비지 컬렉터를 통해 모두 수거하도록 요청을 보내 놓습니다.
        System.gc();
    }
 
    // loadGameTextures() 메소드와 마찬가지로, 또 다른 씬에 필요한 텍스쳐가 있다면
    // 아래처럼 준비해(load) 주면 됩니다. 아래는 메뉴를 위한 텍스쳐들이네요
    public synchronized void loadMenuTextures(Engine pEngine, Context pContext){
        // 메뉴와 관련된 asset 폴더를 "assets/gfx/menu/"로 지정해 줍니다.
        BitmapTextureAtlasTextureRegionFactory.setAssetBasePath("gfx/menu/");
  
        BuildableBitmapTextureAtlas mBitmapTextureAtlas = new BuildableBitmapTextureAtlas(pEngine.getTextureManager() ,800 , 480);
  
        mMenuBackgroundTextureRegion = BitmapTextureAtlasTextureRegionFactory.createFromAsset(mBitmapTextureAtlas, pContext, "menu_background.png");
  
        try {
            mBitmapTextureAtlas.build(new BlackPawnTextureAtlasBuilder<IBitmaptextureatlassource, Bitmaptextureatlas>(0, 1, 1));
            mBitmapTextureAtlas.load();
        } catch (TextureAtlasBuilderException e) {
            Debug.e(e);
        }
  
    }
 
    // 메뉴 씬에서 필요한 것들을 unload하는 메소드고...
    public synchronized void unloadMenuTextures(){
        
        BuildableBitmapTextureAtlas mBitmapTextureAtlas = (BuildableBitmapTextureAtlas) mMenuBackgroundTextureRegion.getTexture();
        mBitmapTextureAtlas.unload();
  
        // 생략 부분~
  
        System.gc();
    }
 
    // 오디오 리소스(사운드나 음악)들에 대한 load 메소드네요
    public synchronized void loadSounds(Engine pEngine, Context pContext){
        // 기본 패스(path)를 정해주고
        SoundFactory.setAssetBasePath("sounds/");
        try {
             // 사운드 객체도 생성하고
            mSound = SoundFactory.createSoundFromAsset(pEngine.getSoundManager(), pContext, "sound.mp3");    
        } catch (final IOException e) {
            Log.v("Sounds Load","Exception:" + e.getMessage());
        }
    } 
 
    // 사운드 관련해서는 한번 로드 하면 게임 전체에서 사용될 수도 있어요.
    // 텍스쳐야 씬마다 사용되는 게 다르겠지만, 사운드는 좀 다르잖아요?
    // 그럴 경우엔 이 메소드(unload)가 필요 없을 수도 있겠네요.
    public synchronized void unloadSounds(){
        // 메모리에서도 수거해버리려면 release() 메소드를 호출해주면 됩니다.
        if(!mSound.isReleased())mSound.release();
    }
 
    // 폰트 같은 경우도 대부분은  전체 게임에 공통적으로 사용되죠.
    public synchronized void loadFonts(Engine pEngine){
        FontFactory.setAssetBasePath("fonts/");
  
        // FontFactory 클래스를 이용해서 Font 객체를 생성합니다.
        mFont = FontFactory.create(pEngine.getFontManager(), pEngine.getTextureManager(), 256, 256, Typeface.create(Typeface.DEFAULT, Typeface.NORMAL),  32f, true, org.andengine.util.adt.color.Color.WHITE_ABGR_PACKED_INT);

        mFont.load();
    }
 
    //혹시나 폰트에 대해서도 unload 메소드가 필요하다면 아래처럼 만들어 주시구요.
    public synchronized void unloadFonts(){
        // Similar to textures, we can call unload() to destroy font resources
        // 텍스쳐와 마찬가지로, 폰트 리소스를 삭제하기 위해  unload() 메소드를 호출해 줍니다.
        mFont.unload();
    }
}


ResourceManager 클래스를 구현해주면, 아주 쉽게 씬(scene)에 필요한 리소스들을 개별적으로 로드해 줄 수가 있다. 스레드와 관련해서도 안정적으로 호출해주기 위해(몇 개의 스레드에서 다중으로 접근할 수도 있으니) 그 메소드들을 모두 synchronized 로 선언한 것도 주의깊게 보길 바란다.

근데, 왜 반말했다 존대했다 그러지? 기분이 좋았다 나빴다 하나?
이제 실제 씬에서 ResourceManager를 어떻게 사용할 것인지만 보면 되겠다.

    @Override
    public void onCreateResources(OnCreateResourcesCallback pOnCreateResourcesCallback){
        //게임에 필요한 텍스쳐를 로드하자
       ResourceManager.getInstance().loadGameTextures(mEngine, this);

        //필요한 폰트도 로드해주자
        ResourceManager.getInstance().loadFonts(mEngine);
  
        //사운드도 필요하면 로드해주자
        ResourceManager.getInstance().loadSounds(mEngine, this);

        pOnCreateResourceCallback.onCreateResourceFinished();
    }


* 상황을 봐가며, load와 unload를 작성해 주면 되겠다. 반드시 짝을 이루는 것은 아니라는 것이다.
* ResourceManager의 인스턴스 변수들이 모두 public 이었던 걸 기억하는 가? 일단 로드 메소드가 호출되어 제대로 로드만 되었다치면 ResourceManager.getInstance().instanceVariable  처럼 접근할 수도 있겠다. 근데, 이게 좋은 방법인지는 모르겠다. 별도로 getter 메소드를 두는 방법도 고려할 수도 있겠다.
* 위 소스 코드에서는 단지 몇 개의 리소스만 존재하기 때문에 아주 간단하다. 하지만, 실제는 서너개로 끝나는 경우는 없다는 것도 기억하자.
* 이 ResourceManager 클래스는  Engine이나 Camera 객체 정보를 저장하고 있진 않다.(그래서, 필요할 때마다 그 정보들을 넘겨주며 load/unload 하도록 요청하고 있다) 하지만, 그 정보들을 매니저가 가지도록 작성할 수도 있다.





게임 데이터의 저장과 로딩

이제 게임 데이터나 게임 세팅 정보를 어떻게 관리할 것인지를 고민할 때인듯 하다. 여기서는 SharedPreference 를 이용한 방법을 보여줄 것이다. (안드로이드 앱을 개발해 본사람이라면 어느 정도는 알고 있을 거라 생각되지만) SharedPreference 클래스는 기본 타입(primitive data types)의 정보를 빠르게 저장하고 불러오는 아주 유용한 클래스이다. 하지만, 데이터 사이즈가 커지면 다른 방법(SQLite 같은)으로 처리하여야 할 것이다.

public class UserData {
    // 리소스 매니저 클래스처럼, 이 클래스도 싱글턴으로 작성한다
    private static UserData INSTANCE;
 
    // shared preference를 위한 파일 이름을 정해주자
    private static final String PREFS_NAME = "GAME_USERDATA";
 
    //아래의 key 들은 shared preference 에디터에 '어떤 데이터를 접근하려 하는지'에 대해 알려주는 용도로 사용된다.
    //이 예제에서는 unlock level 과 sound mute 정보만 가지고 있으므로, 아래처럼 두 개만 필요하다.  
    private static final String UNLOCKED_LEVEL_KEY = "unlockedLevels";
    private static final String SOUND_KEY = "soundKey";

    //데이터를 저장하고 로드할 때 사용될 shared preferences 객체와 데이터를 생성하고
    private SharedPreferences mSettings;
    private SharedPreferences.Editor mEditor;

    // 아직 자물쇠가 풀리지 않은 레벨
    private int mUnlockedLevels;

    // 소리는 켜고 게임할 것인지, 아닌지
    private boolean mSoundEnabled;

    UserData() {
        // 생성자는 여기서도 아무일 안한다.
    }

    public synchronized static UserData getInstance() {
        if(INSTANCE == null){
            INSTANCE = new UserData();
        }
        return INSTANCE;
    }

    public synchronized void init(Context pContext) {
        if (mSettings == null) {

            //shared preference 파일을 불러온다. 아직 없으면 하나 만들고...
            mSettings = pContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);

            //에디터를 정의해서 데이터를 저장할 때를 대비하고
            mEditor = mSettings.edit();

            //현재 게임할 수 있는 레벨을 알아야겠지. UNLOCKED_LEVEL_KEY 값이 존재하지 않으면
            // 게임을 처음 하는 것이니, UNLOCK LEVEL을 1로 지정해주면 되겠다
            mUnlockedLevels = mSettings.getInt(UNLOCKED_LEVEL_KEY, 1);
   
            //사운드 설정 값을 가져온다. 위와 마찬가지로 지정되어진 값이 없으면 그냥 true로...
            mSoundEnabled = mSettings.getBoolean(SOUND_KEY, true);
        }
    }

    //UNLOCK LEVEL 최대치 값. 몇 단계까지 끝낸건지 모르는 상태에선 최대값을 가져오는게 맞지.
    public synchronized int getMaxUnlockedLevel() {
        return mUnlockedLevels;
    }

    //사운드 설정이 어떻게 되었는지를 알려주는 메소드
    public synchronized boolean isSoundMuted() {
        return mSoundEnabled;
    }

    //이 메소드는 UNLOCK LEVEL을 1씩 증가시키는 메소드이다. 
    public synchronized void unlockNextLevel() {
        
        mUnlockedLevels++;

 //한단계 증가 시켰으니, shared preference도 수정해줘야 할 것이다.
        mEditor.putInt(UNLOCKED_LEVEL_KEY, mUnlockedLevels);

 //commit() 메소드를 반드시 호출해줘야 한다. 그래야 변경 저장된다.
        mEditor.commit();
    }

    //위와 마찬가지....
    public synchronized void setSoundMuted(final boolean pEnableSound) {
        mSoundEnabled = pEnableSound;
        mEditor.putBoolean(SOUND_KEY, mSoundEnabled);
        mEditor.commit();
    }
}


루팅된 디바이스의 경우 shared preference 파일에 접근해서 게임데이터를 변경할 수도 있다. 그게 걱정될 경우엔 암호화 할 수도 있다. 자바의 javax.crypto.* 패키지를 활용하면 될텐데, 이게 꼭 필요할 것인지는 잘 살펴야한다. 암호화하고 복호화 하는데 당연히 시간이 걸리기 때문이다. 로딩 시간이 길어지잖아~




No comments:

Post a Comment