2016년 11월 16일 수요일

Project A - Millennium Falcon

이번에는 비트맵과 메시지를 이용한 예제를 만들어 보겠습니다. 화면에서 밀레니엄 팔콘 움직이기.


1. 메시지 처리
이 팔콘이 부드럽게 움직이기 위해서는 터치했을 때 순간이동하는 것이 아니라 천천히 목적지까지 가야 합니다. 그러므로 타이머 메시지를 만들어서 터치하지 않을때도 코드가 실행되도록 해야 합니다.

이런 메시지를 위한 시스템이 Handler입니다.


namespace MillenniumFalcon.Droid
{
    [Activity (Label = "MillenniumFalcon.Droid", MainLauncher = true, Icon = "@drawable/icon")]
    public class MainActivity : Activity
    {
        private Handler timerHandler;

        protected override void OnCreate (Bundle bundle)
        {
            base.OnCreate (bundle);

            // Set our view from the "main" layout resource

            var view = new SpaceView(this);
            SetContentView(view);

            timerHandler = new TimerMessageHandler(view.TimerFunction);
            timerHandler.SendEmptyMessage(0);                            // ①
        }
    }

    internal class SpaceView : Android.Views.View
    {
        public SpaceView(Context context) : base(context)
        {
        }

        internal void TimerFunction(Message msg)
        {
        }
    }

    internal class TimerMessageHandler : Android.OS.Handler
    {
        Action<Message> handle_message;

        public TimerMessageHandler(Action<Message> handler)
        {
            this.handle_message = handler;
        }

        public override void HandleMessage(Message msg)
        {
            SendEmptyMessageDelayed(0, 100);                            // ②
            handle_message(msg);
        }
    }
}

TimerMessageHandler가 메시지를 처리하는 핸들러입니다. 그리고 ①로 표시된 라인이 이 핸들러로 메시지를 보내는 부분입니다.
여기서는 오로지 타이머에 관련된 메시지만 처리하기에 SendEmptyMessage() 함수를 사용했고, 핸들러에서도 메시지를 확인 않고 처리했지만, SendMessage() 함수를 사용해도 됩니다.
SendEmptyMessage()나 SendMessage()에 의해 발생된 메시지에 의해 HandleMessage()함수가 실행됩니다. 여기서는 ②에서처럼 SendEmptyMessageDelayed() 함수에 의해 100밀리초, 즉 0.1초 이후 메시지를 예약합니다. 그리고 delegate로 지정된 함수 - SpaceView.TimeFunction()을 실행하죠. 즉 SpaceView.TimeFunction()함수가 0.1초 간격으로 실행되는 것입니다.


2. BMP 등록
'BMP등록'이라고 했지만, 반드시 bmp파일만 등록할 수 있는 것은 아닙니다. jpg나 png 등 대부분의 그림파일을 등록할 수 있습니다. 내부에서 bmp로 바뀌어 처리됩니다.

MillenniumFalcon.Droid 프로젝트를 보면 다음과 같이 Drawable 폴더가 5개나 있습니다.


하드웨어의 해상도에 따라 자동으로 폴더가 선택되어 BMP파일을 불러옵니다. 만약 필요한 해상도에 파일이 없으면 그와 비슷한 다른 해상도의 파일을 가져옵니다.
여기서는 고해상도인 drawable-xxxhdpi에 넣었지만, 만약 drawable-hdpi에 넣는다면 엄청나게 큰 밀레니엄팔콘이 나옵니다.
만약 그림파일이 하나만 있다면, 해상도에 맞춰 프로그램 내부에서 크기를 조정하면서 일그러지는 현상이 일어나므로, 미리 조정된 크기의 그림을 넣을수 있도록 여러가지로 구분된 것 같습니다.
이렇게 등록된 그림은 "Resource.Drawable.파일이름"으로 등록됩니다. 확장자는 제외되므로, 이를테면 "a.jpg"와 "a.png"를 동시에 등록할 수는 없습니다.


3. BMP 로드
이렇게 drawable에 등록된 그림을 읽어들여야겠죠.
Xamarin에서 그림을 저장하는 객체는 Bitmap입니다. 그러나 그림을 출력하기 위해서는 정보가 더 필요하죠. 특히나 지금처럼 움직이는 우주선을 묘사하기 위해서는 말입니다.

    internal class SpaceView : Android.Views.View
    {
        private class MillenniumFalcon
        {
            private const float speed = 10;

            private Bitmap falcon;

            // 그림 중앙에 맞추기 위해
            private float offX;
            private float offY;

            // 현재위치
            private float curX;
            private float curY;

            // 이동할 위치
            private float goalX;
            private float goalY;

            internal MillenniumFalcon(Android.Views.View view)
            {
                falcon = Android.Graphics.BitmapFactory.DecodeResource(view.Resources,
                                                  Resource.Drawable.MillenniumFalcon); // ①

                // 초기위치 설정
                goalX = curX = offX = falcon.Width / 2;
                goalY = curY = offY = falcon.Height / 2;
            }

            internal void Draw(Canvas canvas)
            {
                canvas.DrawBitmap(falcon, curX - offX, curY - offY, null);   // ②
            }

            internal void MovePoint(float x, float y)
            {
                goalX = x;
                goalY = y;
            }

            internal void TimerFunction()
            {
                float dX = goalX - curX;
                float dY = goalY - curY;
                float distanceToGoal = (float)Math.Sqrt(dX * dX + dY * dY);
                if (distanceToGoal < speed)
                {
                    curX = goalX;
                    curY = goalY;
                }
                else
                {
                    float dx = speed * dX / distanceToGoal;
                    float dy = speed * dY / distanceToGoal;

                    curX += dx;
                    curY += dy;
                }
            }
        }

        MillenniumFalcon falcon;

        public SpaceView(Context context) : base(context)
        {
            falcon = new MillenniumFalcon(this);
        }

        protected override void OnDraw(Canvas canvas)
        {
            falcon.Draw(canvas);
        }

        internal void TimerFunction(Message msg)
        {
            falcon.TimerFunction();           // ④
            Invalidate();
        }

        public override bool OnTouchEvent(MotionEvent e)
        {
            falcon.MovePoint(e.GetX(), e.GetY());     // ③
            return true;
        }
    }

①번이 리소스에서 그림파일을 읽어 비트맵 이미지로 저장하는 부분입니다. 그리고 ②번부분이 캔버스(화면)에 그리는 부분이며, 터치이벤트가 있을 때마다 goal을 새로 설정하고(③), 0.1초마다 goal을 향해 위치를 바꿉니다(④).

실행해보면 알겠지만, 팔콘이 이동은 하는데 방향이 바뀌질 않는군요. 항상 위쪽을 향한채 옆으로 뒤로 움직입니다. 이동방향으로 회전을 할 수 없을까요?

프로그램 내부에서 비트맵을 수정하는 것이 Matrix입니다. 여러가지 행렬변환으로 회전, 축소확대 등 변형된 BMP파일을 새로 만드는 것입니다.

       Bitmap original = Android.Graphics.BitmapFactory.DecodeResource(view.Resources,
                                   Resource.Drawable.MillenniumFalcon);
       Matrix mat = new Matrix();
       mat.SetScale(2, 2);  // x, y방향으로 2배 확대
       mat.PostRotate(30);  // 시계방향으로 30도 회전
       Bitmap newBitmap = Android.Graphics.Bitmap.CreateBitmap(origin, 0, 0,
                                               origin.Width, origin.Height, mat, true);

위 코드는 original 비트맵을 2배 확대한 후 시계방향으로 30도 회전한 새로운 비트맵을 만들어냅니다. 그러므로 여기서도 falcon은 원본으로 남겨두고 falconDraw라는 새로운 비트맵을 만드는 방식으로 수정했습니다.

        private class MillenniumFalcon
        {
            private const float speed = 10;

            private Bitmap falcon;
            private Bitmap falconDraw;

            // 그림 중앙에 맞추기 위해
            private float offX;
            private float offY;

            // 현재위치
            private float curX;
            private float curY;

            // 이동할 위치
            private float goalX;
            private float goalY;

            internal MillenniumFalcon(Android.Views.View view)
            {
                falcon = Android.Graphics.BitmapFactory.DecodeResource(view.Resources,
                                                     Resource.Drawable.MillenniumFalcon);
                Matrix mat = new Matrix();
                mat.SetScale(0.5F, 0.5F);  // 절반으로 축소한 팔콘 만듦
                falconDraw = Bitmap.CreateBitmap(falcon, 0, 0, falcon.Width, falcon.Height,
                                                  mat, true);

                // 초기위치 설정
                goalX = curX = offX = falcon.Width / 2;
                goalY = curY = offY = falcon.Height / 2;
            }

            internal void Draw(Canvas canvas)
            {
                canvas.DrawBitmap(falconDraw, curX - offX, curY - offY, null);
            }

            internal void MovePoint(float x, float y)
            {
                goalX = x;
                goalY = y;

                // 회전
                float dX = goalX - curX;
                float dY = goalY - curY;
                float rad = (float)Math.Atan2(dX, -dY);
                float deg = rad / 0.01745329F;
                Matrix mat = new Matrix();
                mat.SetScale(0.5F, 0.5F);  // 반으로 축소
                mat.PostRotate(deg);       // 회전
                falconDraw = Bitmap.CreateBitmap(falcon, 0, 0, falcon.Width, falcon.Height,
                                                 mat, true);
                offX = falconDraw.Width / 2;
                offY = falconDraw.Height / 2;
            }

            internal void TimerFunction()
            {
                float dX = goalX - curX;
                float dY = goalY - curY;
                float distanceToGoal = (float)Math.Sqrt(dX * dX + dY * dY);
                if (distanceToGoal < speed)
                {
                    curX = goalX;
                    curY = goalY;
                }
                else
                {
                    float dx = speed * dX / distanceToGoal;
                    float dy = speed * dY / distanceToGoal;

                    curX += dx;
                    curY += dy;
                }
            }
        }

대충 되긴 했습니다만, 이것이 완벽하지 않습니다. 터치 관련된 이벤트가 들어올 때마다 falconDraw비트맵을 새로 만들기 때문에 메모리낭비가 심하죠.


이런 Out Of Memory 에러가 나기 쉽습니다.

개선방향으로는 이를테면 5도 간격으로 미리 만들어놓고 각도에 따라 재사용하는 방법이 있습니다만, 그것은 이후의 숙제로 남겨놓고...^^;

댓글 없음:

댓글 쓰기