상세 컨텐츠

본문 제목

JetBoy 따라하기 #2 - 소행성 표시

프로그래밍/Android

by ryujt 2010. 12. 25. 02:36

본문

이번에는 소행성 여러 개를 화면에 스크롤 시켜 지나가도록 하겠습니다.  [그림 1]은 결과 화면 입니다.

[그림 1] 프로그램 실행 결과



리소스 관리에 대한 제안

필자는 데이터에 관련된 코드들은 한 곳에 모아서 사용하는 편입니다.  주로 스스로 만든 MVC 프레임워크를 이용하여 개발을 하고 있습니다.  

데이터를 한 곳에 모아서 관리하는 방법은 프로그램의 규모가 커지고 복잡해 질 때, 그 가치를 발휘합니다.  아직 우리가 만들고 있는 예제가 그다지 복잡한 수준은 아니지만, 이쯤에서 리소스 관리하는 방법을 조금 고민해보도록 하겠습니다.  

이 강좌는 점차적으로 코드의 수준을 높여가면서 진행하고자 계획을 잡고 진행하고 있습니다.  

우선 새로운 패키지 "app.resources"를 생성합니다.  이어 [소스 1]과 같이 Resource.java를 생성합니다.

[소스 1] Resource.java
package app.resources;

import android.content.Context;
import android.content.res.Resources;

public class Resource {

	private static Resource _Instance = new Resource();

	public static Resource getInstance(){
		return _Instance;
	}
	
	private Resource() {}
	  
	private Context _Context = null;
	
	public Resources getResources() {
		if (_Context == null) return null;
		return _Context.getResources();
	}
	
	public void setContext(Context value) {
		_Context = value;
	}

	public Context getContext() {
		return _Context;
	}	
	
}
Resource 클래스는 싱글톤(Singleton) 패턴으로 작성되어 있습니다.  리소스는 불특정 다수의 객체에서 참조 할 수가 있기 때문에, 레퍼런스를 계속 파라메터로 넘겨주는 것은 소스를 상당히 복잡하게 만들 수가 있습니다.  이럴 때에는 싱글톤을 사용하면 클래스의 static 메소드를 이용하여 레퍼런스 변수를 통하지 않고 접근 할 수가 있습니다.

싱글톤 패턴이 익숙하지 않으신 분들은 검색을 통해서 공부하시기 바랍니다.

8: 인스턴스의 레퍼런스를 저장 할 변수(필드) _Instance를 선언하고 있습니다.

10-12: 인스턴스의 레퍼런스를 리턴 합니다.

14: 생성자의 가시성을 private로 낮춰서 외부에서 객체를 생성하지 못하도록 합니다.

18-21: Context 객체에서  Resources 객체를 찾아와 리턴해 줍니다.

23-25: 리소스에 접근하려면 Context 객체가 필요하기 때문에 setter를 작성하였습니다.


이제 배경 이지미 리소스를 관리하는 클래스인 ResourceBackground를 [소스 2]와 같이 작성합니다.

[소스 2] ResourceBackground.java
package app.resources;

import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import app.main.R;

public class ResourceBackground {
	
	private static ResourceBackground _Instance = new ResourceBackground();

	public static ResourceBackground getInstance(){
		return _Instance;
	}
	
	private ResourceBackground() {}
	  
	private Bitmap _Background_A = null;
	private Bitmap _Background_B = null;
	
	public Bitmap getBackground_A() {
		if (_Background_A == null) {
			_Background_A = 
				BitmapFactory.decodeResource(
					Resource.getInstance().getResources(), R.drawable.background_a
				);
		}
		
		return _Background_A;
	}
	
	public Bitmap getBackground_B() {
		if (_Background_B == null) {
			_Background_B = 
				BitmapFactory.decodeResource(
					Resource.getInstance().getResources(), R.drawable.background_b
				);
		}
		
		return _Background_B;
	}

}
역시 마찬가지로 싱글톤 패턴으로 작성되어 있습니다.  getBackground_A()와 getBackground_B() 메소드로 두 장의 이미지를 불러서 사용 할 수 있도록 하였습니다.

15: 라인까지는 Resource 클래스와 마찬가지로 싱글톤 구현에 해당하는 부분입니다.

21: 이미지를 아직 로드해오지 않았을 때만 로딩을 하도록 조건문으로 거르고 있습니다.  다음부터는 다시 로딩하지 않고, 이미 로딩해온 이미지를 리턴하게 됩니다.

22-25: 리소스에서 이미지를 가져 옵니다.  Resources 객체에 Resource.getInstance().getResources()와 같이 접근하고 있습니다.  Resource.getInstance() 여기까지는 싱글톤 패턴에 의해서 Resource 객체를 리턴하게 됩니다.  이후 리턴된 Resource 객체의 메소드인 getResources()를 이용해서 Resources 객체를 최종적으로 리턴하고 있습니다.

대체 이렇게 복잡하게 코드를 작성한 것이 왜 더 효율적이라고 하는 걸까요?  

사실 위의 방식이 무조건 효율적이라고 단언 할 수는 없습니다.  대개 간단한 프로그램에서는 OOP의 힘을 빌리는 것이 오히려 번거로운 짐이 됩니다.  하지만, 프로그램이 복잡해 질 수록 객체로 잘게 나눠서 각각의 차별되는 역활을 나눠주는 것이 좋습니다.  

싱글톤 패턴은 여기 저기 너저분하게 널려 질 수 있는 레퍼런스 정보를 한 곳에 모아서 코드를 보다 쉽게 이해 할 수 있도록 정리하는데 도움이 될 수도 있습니다.  

마치 전역변수를 사용하는 듯한 자유로움을 제공합니다.  하지만, 자칫 전역변수와 같은 단점을 가져 올 수도 있습니다.  따라서, 싱글톤으로 구현된 객체는 해당 객체와 비슷한 논리적 위치에 있는 객체에서만 사용하는 것이 좋습니다.

32-37: 위에서 설명한 21-25: 라인과 동일한 방법을 사용하고 있습니다.


이제 마지막으로 소행성 이미지를 관리하는 ResourceAsteroid 클래스를 [소스 3]과 같이 작성합니다.

[소스 3] ResourceAsteroid.java
package app.resources;

import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import app.main.R;

public class ResourceAsteroid {

	private static ResourceAsteroid _Instance = new ResourceAsteroid();

	public static ResourceAsteroid getInstance(){
		return _Instance;
	}
	
	private ResourceAsteroid() {}
	
    private Bitmap[] _Bitmaps = null;

    public static final int SIZE = 64;
	
	public Bitmap getBitmap(int index) {
    	loadBitmaps();
    	return _Bitmaps[index];
    }
    
    public int getCount() {
    	loadBitmaps();
    	return _Bitmaps.length;
    }
    
    private void loadBitmaps() {
		// 이미 로딩되어 있으면 무시한다.
		if (_Bitmaps != null)  return;

		_Bitmaps = new Bitmap[12];
		
		Resources resources = Resource.getInstance().getResources();  
		
        _Bitmaps[11] = BitmapFactory.decodeResource(resources, R.drawable.asteroid01);
        _Bitmaps[10] = BitmapFactory.decodeResource(resources, R.drawable.asteroid02);
        _Bitmaps[ 9] = BitmapFactory.decodeResource(resources, R.drawable.asteroid03);
        _Bitmaps[ 8] = BitmapFactory.decodeResource(resources, R.drawable.asteroid04);
        _Bitmaps[ 7] = BitmapFactory.decodeResource(resources, R.drawable.asteroid05);
        _Bitmaps[ 6] = BitmapFactory.decodeResource(resources, R.drawable.asteroid06);
        _Bitmaps[ 5] = BitmapFactory.decodeResource(resources, R.drawable.asteroid07);
        _Bitmaps[ 4] = BitmapFactory.decodeResource(resources, R.drawable.asteroid08);
        _Bitmaps[ 3] = BitmapFactory.decodeResource(resources, R.drawable.asteroid09);
        _Bitmaps[ 2] = BitmapFactory.decodeResource(resources, R.drawable.asteroid10);
        _Bitmaps[ 1] = BitmapFactory.decodeResource(resources, R.drawable.asteroid11);
        _Bitmaps[ 0] = BitmapFactory.decodeResource(resources, R.drawable.asteroid12);
    }
	  
}
역시 16: 라인까지는 싱글톤에 관한 코드입니다.  그리고 주요 멤버들의 설명은 아래와 같습니다.
  • public static final int SIZE = 64;
    • 소행성의 가로와 세로 크기가 같기 때문에 하나의 상수로 표현하고 있습니다.  이처럼 리소스에 대한 정보를 하나로 묶어두면 응집도가 높아져서 코드를 관리하기가 휠씬 수월해 지는 장점이 있습니다.
  • public Bitmap getBitmap(int index)
    • index에 해당하는 이미지를 리턴합니다.
  • public int getCount()
    • 소행성 이미지가 애니메이션 효과를 나타내기 위해서 몇 개의 이미지를 사용하고 있는 지 리턴해 줍니다.
23: 이미지를 먼저 로딩하고, index에 해당하는 이미지를 리턴 합니다.

28: 이미지를 먼저 로딩하고, 이미지 갯수를 리턴 합니다.

40-51: 배열에 이미지를 로딩합니다.


일단 구현은 좀 더 복잡해 졌지만, 사용 법은 아래와 같이 상단히 간답합니다.  그리고, 모든 이미지는 필요한 시점에 로딩하여 메모리를 절약하거나 필요 없을 때는 메모리에서 제거 하는 등의 작업을 캡슐화 할 수 있습니다.  그리고 가장 중요한 것은, 이런 변화가 생겨도 사용법은 언제나 같아서 다른 코드에 영향을 주지 않게 보호 할 수 있다는 점 입니다.  이런 기법은 나중에 천천히 다뤄보도록 하겠습니다.
	// 배경 가장 먼 거리 이미지를 가져오기
	Bitmap _Background_A = ResourceBackground.getInstance().getBackground_A();

	// 배경 가장 가까운 거리 이미지를 가져오기
	Bitmap _Background_B = ResourceBackground.getInstance().getBackground_B();

	// 소행성 애니메이션 효과 용 이미지의 개수 가져오기
	int count = ResourceAsteroid.getInstance().getCount();

	// index에 해당하는 순서의 소행성 이미지 가져오기
	Bitmap _Asteroid = ResourceAsteroid.getInstance().getBitmap(int index);

싱글톤 패턴을 이용하여 데이터 정보를 한 곳에 모아두고 사용하는 방법이 다소 낯 설고 이해하기가 어렵다면, 단순하게 사용하는 방법에만 집중하시기 바랍니다.  가끔 입문자분들은 자신이 이해하기 어려운 부분에 너무 공을 들인 나머지, 쉽게 얻을 수 있는 지식을 소흘히 하곤 합니다.  

쉽게 얻을 수 있는 지식을 쌓아가다보면, 지금 다소 어려워서 지나쳤던 것들이, 언젠가는 자연스럽게 이해가 가는 날이 오게 됩니다.



배경 이미지 처리를 다시 수정

지난 번 작성했던 배경 이미지 처리 부분을 새로 변경된 리소스 관리 클래스를 적용하여 수정하도록 하겠습니다.

만약 여러분들이 입문자라면, 이전 코드를 무시하시고 처음부터 다시 코딩해보시기 바랍니다.  프로그래밍은 눈보다 손으로 익히는 것이 좋습니다.

우선 Main.java는 [소스 4]와 같습니다.

[소스 4] Main.java
package app.main;

import ryulib.game.GamePlatform;
import android.app.Activity;
import android.os.Bundle;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import app.resources.Resource;

public class Main extends Activity {
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        
        Resource.getInstance().setContext(this);

        _GamePlatform = new GamePlatform(this);
        _GamePlatform.setLayoutParams(
        		new LinearLayout.LayoutParams(
        				ViewGroup.LayoutParams.FILL_PARENT, 
        				ViewGroup.LayoutParams.FILL_PARENT, 
        				0.0F
        		)
        );
        
        setContentView(_GamePlatform);
        
        _Background = new Background(null);
        _GamePlatform.AddControl(_Background);
        
        _Asteroids = new Asteroids(null);
        _GamePlatform.AddControl(_Asteroids);        
    }
    
    private GamePlatform _GamePlatform = null;
    private Background _Background = null;
    private Asteroids _Asteroids = null;
    
}
16: Resource의 인스턴스가 Resources 객체를 얻기 위해서는 Context 객체가 필요합니다.  따라서, setContext(this)를 통해서 Context 객체를 전달해주고 있습니다.

29-30: 배경 이미지를 처리하는 Background의 객체를 생성하고, GamePlatform 객체에 컨트롤을 등록 시킵니다.  이후 이미 설명한 바와 같이 지속적으로 반복되면서 배경 이미지를 그려주게 됩니다.

32-33: 여러 개의 소행성 객체를 관리하는 Asteroids의 객체를 생성하고, 역시 GamePlatform 객체에 컨트롤을 등록 시킵니다.

크게 변한 곳은 없고, 싱글톤 패턴으로 작성된 Resource에 Context 객체를 넘기는 부분만 추가된 셈 입니다.


Background 클래스의 구현은 [소스 5]와  같이 변경되었습니다.

[소스 5] Background.java
package app.main;

import ryulib.game.GameControl;
import ryulib.game.GameControlGroup;
import ryulib.game.GamePlatformInfo;
import android.graphics.Canvas;
import android.graphics.Paint;
import app.resources.ResourceBackground;

public class Background extends GameControl {

	private static final int _BACKROUND_SPEED_SLOW = 25;
	private static final int _BACKROUND_SPEED_FAST = 50;

	public Background(GameControlGroup gameControlGroup) {
		super(gameControlGroup);
		
		_Background_A.setBitmap(ResourceBackground.getInstance().getBackground_A());
		_Background_A.setSpeed(_BACKROUND_SPEED_SLOW);

		_Background_B.setBitmap(ResourceBackground.getInstance().getBackground_B());
		_Background_B.setSpeed(_BACKROUND_SPEED_FAST);
	}

	private Canvas _Canvas = null;
	private Paint _Paint = null;
	
	private BackgroundImage _Background_A = new BackgroundImage();
	private BackgroundImage _Background_B = new BackgroundImage();
	
	@Override
	protected void onStart(GamePlatformInfo platformInfo) {
		_Canvas = platformInfo.getCanvas();
		_Paint = platformInfo.getPaint();
	}
	
	@Override
	protected void onDraw(GamePlatformInfo platformInfo) {
		long tick = platformInfo.getTick();
		
		_Background_A.Draw(_Canvas, _Paint, tick);
		_Background_B.Draw(_Canvas, _Paint, tick);
	}
	
}
18, 21: 이전에 BitmapFactory를 통해서 이미지를 가져오던 것이 싱글톤 패턴으로 처리 된 ResourceBackground를 통해서 가져오고 있습니다.  

나머지는 달라진 것이 없습니다.


BackgroundImage 클래스는 [소스 6]과 같이 변경되었습니다.

[소스 6] BackgroundImage.java
package app.main;

import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;

public class BackgroundImage {

	private int _X01 = 0;
	private int _X02 = 0;
	private Scroll _Scroll = new Scroll();

	// Property 
	private Bitmap _Bitmap = null;
	
	public void Draw(Canvas canvas, Paint paint, long tick) {
		// 현재 위치에 먼 거리 배경 이미지 그리기
		if ((_Bitmap.getWidth()+_X01) > 0) {
			canvas.drawBitmap(_Bitmap, _X01, 0, paint);
		}
		
		// 첫 번째 이미지가 화면을 꽉 채우지 못할 경우, 나머지 공간에 두 번째 이미지를 그린다.
		if ((_Bitmap.getWidth()+_X01) <= canvas.getWidth()) {
			canvas.drawBitmap(_Bitmap, _X02, 0, paint);
		}
		
		// 초당 _Scroll.getSpeed() 만큼의 속도로 운석을 이동 한다.
		_X01 = _X01 - _Scroll.Move(tick);
		
		// 뒤에 따라와야하는 이미지의 좌표
		_X02 = _X01 + _Bitmap.getWidth();
		
		// 두 번째 이미지가 화면을 꽉 채우지 못하면, 지나간 첫 번째 이미지를 재 활용한다.
		if (_X02+(_Bitmap.getWidth()) <= canvas.getWidth()) {
			int temp = _X01;
			_X01 = _X02;
			_X02 = temp + _Bitmap.getWidth();
		}
	}
	
	public void setBitmap(Bitmap value) {
		_Bitmap = value;
	}
	public Bitmap getBitmap() {
		return _Bitmap;
	}

	public void setSpeed(int value) {
		_Scroll.setSpeed(value);
	}

	public int getSpeed() {
		return _Scroll.getSpeed();
	}

}
11: 스크롤을 위한 Scroll 클래스를 추가하여, 스크롤에 관한 코드를 위임하였습니다.

28: Scroll 클래스를 통해서 이미지의 좌표를 스크롤 하고 있습니다.


Scroll 클래스는 [소스 7]과 같이 작성되어 있습니다.

[소스 7] Scroll.java
package app.main;

public class Scroll {
	
	public Scroll() {
		super();
	}

	public Scroll(int speed) {
		super();
		
		_Speed = speed;
	}

	private int _Distance = 0;
	private long _DX = 0;
	
	private int _Speed = 0;
	
	public void Init() {
		_Distance = 0;
		_DX = 0;
	}
	
	public int Move(long tick) {
		int result = 0;
		
		_DX = _DX + (_Speed * tick);
		
		if (_DX >= 1000) {
			 result = (int) _DX / 1000;
			_DX = _DX - (result * 1000);
			
			_Distance = _Distance + result;
		}
		
		return result;
	}
	
	public int getDistance() {
		return _Distance;
	}

	public void setSpeed(int value) {
		_Speed = value;
	}

	public int getSpeed() {
		return _Speed;
	}

}
9-13:  스크롤 속도를 지정해서 객체를 생성 할 수 있는 생성자를 추가하였습니다.

20: Init() 메소드는 스크롤 처리했던 정보를 초기화 합니다.

25: Move() 메소드는 시간 간격 tick 만큼 스크롤을 진행하고, 얼마나 이미지가 이동되어야 하는 지를 정수값으로 리턴합니다.

40: getDistance() 메소드는 총 이동한 거리를 리턴 합니다.



소행성 처리

이제 원래의 목적인 소행성을 처리하도록 하겠습니다.  소행성을 하나가 아닌 여러 개를 표시하고 관리하기 위해서 Asteroids 클래스를 [소스 8]과 같이 작성하였습니다.

[소스 8] Asteroids.java
package app.main;

import java.util.Random;

import app.resources.ResourceAsteroid;

import ryulib.game.GameControl;
import ryulib.game.GameControlGroup;
import ryulib.game.GamePlatformInfo;

public class Asteroids extends GameControl {

	public Asteroids(GameControlGroup gameControlGroup) {
		super(gameControlGroup);
	}
	
	private Random _Random = new Random();
	private long _AdventInterval = 250;
	private long _AdventCount = 0;
	
	@Override
	protected void onTick(GamePlatformInfo platformInfo) {
		long tick = platformInfo.getTick();
		
		_AdventCount = _AdventCount + tick;
		if (_AdventCount >= _AdventInterval) {
			_AdventCount = _AdventCount - _AdventInterval;
			
			// 주기적으로 무조건 생성하지 않고, 랜덤하게 발생 시킨다.
			if (_Random.nextInt(2) == 1) createAsteroid(platformInfo);
		}
	}
	
	private void createAsteroid(GamePlatformInfo platformInfo) {
		Asteroid asteroid = 
			new Asteroid(
					platformInfo.getCanvas().getWidth(), 
					_Random.nextInt(platformInfo.getCanvas().getWidth()-ResourceAsteroid.SIZE)
			);
		getGameControlGroup().AddControl(asteroid);
	}
	
}
22: 게임 컨트롤(GameControlGroup과 GameControl)은 주기적으로 실행되는 메소드가 두 개 있습니다.  하나는 이미 계속 사용해왔던 onDraw()이며, 나머지는 지금 설명하는 onTick()입니다.  onTick()은 onDraw()와 전혀 구별되지 않습니다.  즉, 둘 다 같은 목적으로 사용해도 상관없습니다.  굳이 나눠둔 이유는 onTick()은 주기적으로 실행되어야 하는 논리 코드를 작성하고, onDraw()는 그리기 자체에만 집중시켜서 코드를 간결하게 하고 싶었기 때문입니다.  두 메소드가 전혀 차이가 없는 것은 아니고, 모든 게임 컨트롤들의 onTick()이 실행 된 다음 onDraw()가 나중에 실행 됩니다.

25: _AdventCount에 시간 간격 tick을 계속 더해 갑니다.

26: _AdventCount가 소행성 출현 간격 _AdventInterval보다 크거나 같으면 소행성 생성 처리를 시작합니다.

27: _AdventCount는 _AdventInterval 차감하여 _AdventInterval 간격 마다 소행성 생성이 실행되도록 합니다.

30: 너무 규칙적으로 소행성이 출현되지 않도록, 랜덤을 이용해서 가끔씩 걸러냅니다.

35-39: 소행성 클래스 Asteroid의 객체를 생성합니다.  생성자의 파라메터는 어느 좌표에서 소행성이 시작되는 지를 넘겨주어야 합니다.

40: 생성된 소행성 객체를 Asteroids의 객체와 같은 그룹에 추가 합니다.  Asteroids의 객체는 GamePlatform에 추가되어 있기 때문에, Asteroid의 객체도 GamePlatform에 같이 포함됩니다.  즉, GamePlatform도 게임 컨트롤 그룹처럼 취급됩니다.


이제 마지막으로 Asteroid 클래스를 [소스 9]와 같이 작성합니다.

[소스 9] Asteroid.java
package app.main;

import ryulib.game.GameControl;
import ryulib.game.GamePlatformInfo;
import android.graphics.Canvas;
import android.graphics.Paint;
import app.resources.ResourceAsteroid;

public class Asteroid extends GameControl {

	private static final int _ANIMATIONINTERVAL = 100;
	private static final int _SPEED = 100;

	public Asteroid(int x, int y) {
		super(null);
	
		_X = x;
		_Y = y;
	}

	private Canvas _Canvas = null;
	private Paint _Paint = null;
	
	private int _X = 0;
	private int _Y = 0;
	private Scroll _Scroll = new Scroll(_SPEED);
	
    private int _BitmapIndex = 0;
    private long _TickCount = 0;
    
	@Override
	protected void onStart(GamePlatformInfo platformInfo) {
		_Canvas = platformInfo.getCanvas();
		_Paint = platformInfo.getPaint();
	}
	
	@Override
	protected void onTick(GamePlatformInfo platformInfo) {
		long tick = platformInfo.getTick();
		
		_TickCount = _TickCount + tick;
		if (_TickCount >= _ANIMATIONINTERVAL) {
			_TickCount = 0;
			_BitmapIndex++;
			if (_BitmapIndex >= ResourceAsteroid.getInstance().getCount()) {
				_BitmapIndex = 0;
			}
		}
		
		// 초당 _Scroll.getSpeed() 만큼의 속도로 운석을 이동 한다.
		_X = _X - _Scroll.Move(tick);

		// 화면에서 사라지면 삭제 한다.
		if (_X < -(ResourceAsteroid.SIZE)) this.Delete(); 
	}
	
	@Override
	protected void onDraw(GamePlatformInfo platformInfo) {
		_Canvas.drawBitmap(
				ResourceAsteroid.getInstance().getBitmap(_BitmapIndex), 
				_X, _Y, 
				_Paint
		);
	}
	
}
"RyuLib.Game for Android 시작하기"에서 이미 작성했던 것과 크게 다르지 않습니다.

26: Scroll 객체를 생성 할 때, 생성자에 스크롤 속도를 지정해서 생성하고 있습니다.

41-48: 소행성 이미지들을 차례로 표시하면서 애니메이션 효과를 주고 있습니다.

51: Background와 마찬가지로 Scroll 객체를 통해서 소행성의 좌표를 스크롤 하고 있습니다.



정리

지금까지 리소스를 보다 효율적으로 작성하기 위한 방법과 소행성 처리에 대해서 설명하였습니다.  이번 강좌에서 제안했던 방법은 앞으로 작성 할 게임들이 점점 복잡해졌을 때를 그 가치가 빛나게 될 것 입니다.

이제 앞으로는 비행기 처리와 미사일 처리 등을 통해서 충돌 테스트에 대한 설명을 진행 할 것 입니다.



소스


관련글 더보기