[유니티] 터치이벤트 처리하기

유니티의 터치(Touch) 처리 문제

마우스 관련 이벤트는 MonoBehavior를 상속하면 OnMouseDown 이 수신되므로 크게 신경쓰지 않는 편입니다만, 터치이벤트는 상당히 까다롭습니다. 우선 터치이벤트의 실체를 생각해보죠.

  • 동시에 여러 개의 터치 포인트가 발생할 수 있다(손가락 두개 이상을 동시 터치하면..)
  • 각 포인트마다 상태가 서로 다를 수 있다(검지는 덴 상태로 중지는 뗀다던가..)
  • 포인트의 좌표는 2D상의 좌표다(유니티의 객체는 3D 좌표계인데..)
  • 터치이벤트는 따로 시스템이 알려주지 않는다(단지 상태를 조사할 수 있을 뿐)

가장 큰 차이점은 마우스의 경우 발생하면 통보해주는 이벤트 방식인데 비해, 터치는 상태를 매번 조사해서 처리해야 한다는 점입니다. 터치도 발생하면 통보를 받기 위해 문제를 차근차근 해결해보죠.

터치이벤트를 구글신에게 물어보면..

온통 Ray처리하고 OnMousedown에게 보낸다 라는 결과가 넘쳐 납니다(다들 이것만 하나…???)
이 포스팅은 터치된 화면 좌표로 3D상의 객체를 찾는 것에 대해 중점적으로 다루지 않습니다(뒤에 살짝 예제로..^^)

보다 편리하게 터치를 이벤트 형태로 사용할 수 없을까 에 중심을 두고 이벤트-리스너(Event&Listener) 시스템을 구축해 갑니다. 예를들어 터치이벤트처리가 등록된 리스너에게는 화면 상의 x, y좌표를 알려줄 뿐 그 후의 Ray작업은 리스너 안에서 처리할 문제로 바라보는 식입니다.

시스템이 알려주지 않는 문제

터치에 대해 별도로 시스템이 변화를 알려주지(Notification)하지 않기 때문에 게임루프에 걸 수 밖에 없습니다. 따라서 메인이 될 게임루프객체의 Update에서 처리해야겠죠. 터치이벤트를 만드려면 먼저 input의 touchCount를 이용하여 현재 터치된 포인트가 몇 개인지 얻어와야 합니다. 다음 코드를 보죠.

void Update(){
	int count = Input.touchCount;
	if( count == 0 ) return; //할 일이 없다.
	//실제touch처리
}

counter가 0이면 변화가 일어난 터치포인트가 없다는 뜻입니다. 이제 각 포인트를 돌며 진짜 아이디(fingerId)와 상태(phase)를 얻어봅시다. Input에 담긴 touch객체의 순번은 무의미한 순서일 뿐, 실제로 몇 번째로 손 덴 포인트인가 를 나타내지 않습니다(지랄같죠!) 반드시 포인트의 fingerId로 확인해야 합니다.

void Update(){
	int count = Input.touchCount;
	if( count == 0 ) return; //할 일이 없다.
	for( int i = 0 ; i < count ; i++ ){
		Touch touch = Input.GetTouch(i);
		Debug.Log( "id:" + touch.fingerId + ",phase:" + touch.phase );
	}
}

//실행 후 화면을 터치하면..
//  id:0,phase:Began 터치시작
//  id:0,phase:Moved 드래그중
//  id:0,phase:Ended 화면에서 뗌

재밌는 점은 Update에서 실행됨에도 불구하고 상태의 변화를 보면 Began, Moved, Ended가 전부 확실하게 발생하고 있습니다. 이제 이걸 어떻게 할까요. 아래의 다이어그램이 앞으로 일어날 일입니다.

d1

 

델리게이트(Delegate)와 이벤트(Event)

c#언어를 가르치는 포스트는 아니지만, 그렇다고 이걸 설명 안하면 이해가 안될 수도 있으니 어쩔 수 없이 설명하기로 했습니다.

c#의 델리게이트와 이벤트를 이미 잘 아시는 분은 이 섹션을 건너뛰세요!

이 섹션에서는 다음과 같은 항목을 다룹니다.

  • 함수선언(Function Prototype)
  • 델리게이트(Delegate)
  • 이벤트(Event)

뭐 이래서야 본격 c#강좌입니다 =.=; 하지만 이것을 이해하지 못하면 c#에서 어떻게 이벤트 시스템을 구축하는지 알 수 없습니다. 각 내용은 매우 쉬우니 차근차근 진행해보죠.

함수선언

함수선언은 함수원형(Prototype)이라고도 하고 시그니처(signiture)라고도 합니다. 간단히 말해 함수의 본문이 있는 몸체가 없는 형태입니다.

함수에서 몸체를 제거하면 남는건 선언부의 내용인데 다음을 포함합니다.

  • 반환되는 값의 자료형
  • 함수의 이름
  • 각 인자의 자료형과 이름

예를 들어 두 개의 값을 더하는 plus라는 함수를 생각해보죠.

int plus( int a, int b ){
	return a + b;
}

이 완전한 함수에서 몸체를 제거하면 int plus( int a, int b ) 만 남습니다. 각 다음에 대응하죠.

  • 반환되는 값의 자료형 = int
  • 함수의 이름 = plus
  • 각 인자의 자료형과 이름 = int a, int b

함수 선언은 c계열의 언어에서 헤더와 본문을 분리하여 컴파일러에게 링크걸거나 공개범위를 정하기 위해 사용되었습니다.
하지만 함수형 언어의 특성이 대폭 반영되면서 c#에서는 이를 전혀 다른 방식으로 활용하게 되었습니다.

델리게이트

델리게이트는 클래스의 속성에 함수선언을 잡아두고 나중에 진짜 함수를 그 속성에 할당하여 사용하게 하는 시스템입니다(..만 방금 이 말이 무슨 말인지 모르시는 분에게는 쉬운 설명을 해드려야 하는군요!)

다시 앞의 plus 함수선언을 생각해보겠습니다. 그것을 어떤 클래스의 속성에 델리게이트 키워드로 잡아보죠.

classTest : MonoBehaviour{
	delegate int plus( int a, int b );
}

걍 함수선언 앞에 delegate라는 키워드를 붙이면 클래스의 속성으로 변신합니다. 이제 Test클래스에는 plus라는 델리게이트가 존재하고 현재는 그 함수선언만 있을 뿐 그 안에 아무것도 들어있지 않습니다. 이제 거기에 뭔가 할당해보죠.

class Test : MonoBehaviour{
	delegate int plus( int a, int b );

	void Start(){
		plus = ( a, b ) =>{
			return a + b;
		};
	}
}

문법이 생소한가요? 이것은 c#의 람다식이라는 문법입니다. 일단 델리게이트로 선언된 plus에 진짜 함수를 채우는 과정을 생각해봅시다. 이미 델리게이트에 함수선언이 온전히 들어가 있으니 뭘 채우면 되는가입니다.

  • 인자의 진짜 이름 : 델리게이트의 함수선언에 있는 인자 이름은 맘대로 바꿀 수 있습니다.
  • 함수 본문 : =>로 연결하여 {..}안에 진짜 함수 본문을 기술하면 됩니다.

뭐 이렇게 생각하니 그리 이상하지도 않습니다. 괄호 안에 인자 이름만 쓴 이유는 이미 자료형은 함수선언에 있기 때문이고, 반대로 이름을 다시 적는 이유는 함수 본문에서 이름을 맘대로 쓸 수 있게 해주는 배려입니다. =>라는 특수 기호를 이용해 {…} 함수 본체를 연결하면 함수의 완전한 정의가 되는거죠. 그럼 이제 호출해서 사용하면 됩니다.

class Test : MonoBehaviour{
	delegate int plus( int a, int b );

	void Start(){
		plus = ( a, b ) =>{
			return a + b;
		};
		int result = plus( 4, 5 ); //9
	}
}

일단 델리게이트에 진짜 함수를 할당한 이후부터는 그냥 함수처럼 사용하면 됩니다. 델리게이트에는 람다식을 통한 할당 외에도 원래 있던 메서드를 할당해도 무관합니다. 물론 이 때 그 메서드가 함수선언과 동일한 자료형을 가져야합니다.

class Test : MonoBehaviour{
	delegate int plus( int a, int b );

	//클래스의 메서드
	int method( int A, int B ){
		return A + B;
	}

	void Start(){
		//메서드를 직접 할당
		plus = method;
		int result = plus( 4, 5 ); //9
	}
}

그럼 처음부터 걍 메서드를 호출하면 되는데 왜 귀찮게 델리게이트를 사용하나요? 그것은 전략패턴(Strategy Pattern)의 내제화입니다. 전략패턴은 알고리즘별로 처리하는 객체를 여러 개 만들어 필요에 따라 교체해가면서 사용하는 패턴입니다. 아래에 간단한 예를 보죠.

//우선 부모 클래스를 정의한다.
abstract class Weapon{
	abstract public void attack();
}

//부모를 상속한 자식을 만들자
class Sword : Weapon { //칼부림
	override public void attack(){
		Debug.Log( "스렁~" );
	}
}

//두번 머거
class Bow : Weapon { //활부림
	override public void attack(){
		Debug.Log( "슉~" );
	}
}

이렇게 attack이 가능한 Sword와 Bow 클래스를 정의했으니 실제 주인공이 자유롭게 무기를 바꿔 공격하게 해봅시다.

class Charater : MonoBehaviour {

	//무기
	private Weapon w;

	void Start(){
		w = new Sword(); //일단 칼을 쥐어준다.
		attack(); //공격 스렁~

		w = new Bow(); //활도 쥐어보자
		attack(); // 공격 슉~
	}

	public void attack(){
		w.attack();
	}
}

여기서 주인공은 바로 Character클래스에 정의된 attack메서드입니다.
외부에선 Character.attack()만 호출하면 Character가 w에 뭘 지정하고 있냐에 따라 attack()의 작동이 달라지게 됩니다. 즉 외부에서는 주인공이 무슨 무기를 갖고 있는지 관심이 없고 그저 공격만 시키면 알아서 반응하라는거죠. 여기서 말하는 외부는 키보드나 마우스나 터치라고 생각하면 됩니다.
이러한 전략 패턴을 사용하려면 적어도 위에 등장한

  • 전략객체의 인터페이스(Weapon),
  • 진짜 전략객체들(Sword, Bow),
  • 그리고 그걸 갖고 실제 사용하는 객체(Character)

가 기본적으로 필요하고 게다가 알고리즘이 추가될 때마다 전략객체를 만들어야 합니다. 너무 번잡한거죠. c#은 이를 한방에 해결합니다.

class Charater : MonoBehaviour {

	delegate void attack();

	void sword(){
		Debug.Log( "스렁~" );
	}
	void bow(){
		Debug.Log( "슉~" );
	}

	void Start(){
		attack = sword; //칼
		attack(); //공격 스렁~

		attack = bow; //활
		attack(); // 공격 슉~
	}
}

동적으로 attack의 작동을 바꿀 수 있다는 점에서 전략객체와 동일하지만 훨씬 간단하게 처리가 됩니다[1. 실제로는 attack을 Start함수에서 바꾸는게 아니라 setSword() 함수 등이 있어 외부에서 호출하게 됩니다.]

이벤트

드디어 최종 보스인 이벤트에 도달했습니다. 이벤트는 쉽게 설명해 델리게이트의 배열입니다. 델리게이트는 함수를 할당한 뒤 호출하면, 오직 그 할당된 함수 하나만 호출되는데 비해 이벤트는 몇 개라도 함수를 추가할 수도 있고 다시 제거할 수도 있습니다. 그리고 이벤트를 호출하면 할당된 모든 함수를 호출합니다. 만약 이벤트가 없다면 다음과 같이 구축해야 합니다.

class Charater : MonoBehaviour {
	//정적으로 바꾸자
	static public void sword(){
		Debug.Log( "스렁~" );
	}
	static public void bow(){
		Debug.Log( "슉~" );
	}
	delegate void attack();

	//해당 델리게이트의 배열을 만들고 각각 생성하여 할당한다
	attack[] weapons = new attack[]{
		new attack( Charater.sword ), new attack( Charater.bow )
	};

	void Start(){
		//전부 호출하자!
		for( int i = 0 ; i < weapons.length ; i++ ) weapons[i]();
		// 스렁~, 슉~
	}
}

일단 여러 개의 델리게이트를 등록해서 동시에 호출할 수는 있습니다만 너무 힘들고 괴롭습니다. 이를 해결한 것이 이벤트죠. 이제 이걸 이벤트로 바꿔보겠습니다.

class Charater : MonoBehaviour {

	delegate void attack();

	//해당 델리게이트의 이벤트를 선언한다.
	event attack weapons;

	void Start(){
		//이벤트에 등록, 걍 이벤트에 더해주면 된다!
		weapons += sword;
		weapons +=  bow;

		//이벤트 자체를 호출
		weapons();
		// 스렁~, 슉~
	}

	public void sword(){
		Debug.Log( "스렁~" );
	}
	public void bow(){
		Debug.Log( "슉~" );
	}
}

뭔가 엄청나게 간단해지지 않았나요. c#은 기존 언어의 불편하고 반복적인 부분을 상당히 언어 내부에 문법으로 처리해버렸습니다. 특정 이벤트에 추가적인 함수를 등록하거나 제거하는건 간단히 +=과 -=로 가능합니다.

이상으로 c#언어의 간단한 소개를 했습니다(…지금 무슨 포스팅을 쓰고 있는건가 싶음..)

 

터치이벤트(Touch Event)를 만들자

위에서 가당치 않게 c#의 기능을 너무 길게 설명해 버렸습니다. 이제 진짜 목적인 터치 이벤트를 만들어봅시다.

통합 터치이벤트

터치 이벤트는 동시에 여러 개의 포인트가 각각 다른 상태로 발생하기 때문에 어떤 타이밍으로 begin, move, end를 발생시킬지 미묘합니다. 일단 각 포인트에서 하나라도 해당 이벤트가 발생되면 그 이벤트를 무조건 쏘는 걸로 하겠습니다. 이벤트를 수신하는 리스너에게는 터치포인트의 리스트를 보내도록 하죠. 그럼 리스너함수의 형은 다음과 같은 델리게이터로 정의할 수 있습니다.

delegate void listener( ArrayList touches );

그럼 began, move, end에 대해 각각 이벤트를 정의합시다.

delegate void listener( ArrayList touches );

event listener touchBegin;
event listener touchMove;
event listener touchEnd;

실제 이벤트 호출을 구축하기 위해 처음 만들었던 Update로 돌아가겠습니다(까마득한 느낌이 나네요!) 세 개의 이벤트 플래그를 설정해 포인트 한 개라도 상태에 걸리면 활성화시켜 이벤트를 발생시키도록 개조해보죠.

delegate void listener( ArrayList touches );
//귀찮다 줄이자
event listener touchBegin, touchMove, touchEnd;

void Update(){
	int count = Input.touchCount;
	if( count == 0 ) return;

	//이벤트를 체크할 플래그
	bool begin, move, end;
	begin = move = end = false;

	//인자로 보낼 ArrayList;
	ArrayList result = new ArrayList();

	for( int i = 0 ; i < count ; i++ ){
		Touch touch = Input.GetTouch(i);
		result.Add( touch ); //보낼 인자에 추가
		if(touch.phase==TouchPhase.Began&&touchBegin!=null) begin = true;
		else if(touch.phase==TouchPhase.Moved&&touchmove!=null) move = true;
		else if(touch.phase==TouchPhase.Ended&&touchend!=null) end = true;
	}

	//포인트중 하나라도 상태를 가졌다면..
	if( begin ) touchBegin( result);
	if( end ) touchEnd( result);
	if( move ) touchMove( result);
}

이런 느낌으로 개별 이벤트에 등록된 리스너들에게 통보할 수 있게 되었습니다. 이제 등록해서 사용해 봅시다. 귀찮으니 간단히 람다식으로 갑니다[2. 어려우면 델리게이트와 이벤트 섹션의 델리게이트 부분에 설명이 있으니 복습!]

void Start(){
	touchBegin += ( touches )=>{ Debug.Log( "begin" ); };
	touchEnd+= ( touches )=>{ Debug.Log( "end" ); };
	touchMove+= ( touches )=>{ Debug.Log( "move" ); };
	//터치하면 각각 begin, end, move 호출
}

리스너의 처리가 귀찮다!

실제 간단한 로그가 아니라

  1.  begin에서 첫번째 포인터의 좌표를 얻어 시작점으로 설정하고
  2.  move에서 시작점에서의 이동한 거리를 보여주며
  3.  up에서 최종적으로 이동한 거리를 보여주는

리스너를 작성해봅시다. 일단 시작점으로부터의 이동거리를 측정하는 이유는 터치로 드래그하려면 시작점으로부터 얼마나 떨어진 건지 알아야 하기 때문입니다.
또한 2, 3번의 항목을 자세히 보니 동일한 기능을 수행함으로 리스너는 하나만 있어도 될 것 같습니다.

void Start(){
	touchBegin += begin;
	touchEnd+= moveEnd;
	touchMove+= moveEnd;
};

private float beginX, beginY; //시작위치기억

void begin( ArrayList touches ){
	//첫번째 포인트 얻기
	Touch point;
	foreach (Touch i in list){
		if( i.fingerId == 0 ){
			point = i;
			break;
		}
	}

	//시작좌표기억
	beginX = point.position.x;
	beginY = point.position.y;

	//좌표 표시
	Debug.Log( beginX + "," + beginY );
}

void moveEnd( ArrayList touches ){
	//첫번째 포인트 얻기
	Touch point;
	foreach (Touch i in list){
		if( i.fingerId == 0 ){
			point = i;
			break;
		}
	}

	//시작점부터의 거리
	float dx, dy;
	dx = point.position.x - beginX;
	dy = point.position.y - beginY;

	//좌표 표시
	Debug.Log(
		point.position.x + "," + point.position.y + "," +
		dx + "," + dy
	);
}

완전 귀찮습니다. 특히 fingerId를 조사하여 몇 번째 손가락인지 찾는 부분과 begin시점의 좌표를 기억해뒀다 차이를 계산하는 부분도 반복적인 부분입니다. 이를 전부 커버하는 것과 이벤트 타입을 알 수 있게 보완해 봅시다.

손가락별 이벤트 리스너

새로운 이벤트는 개별 손가락 포인트에 대해 각각 이벤트가 발생하도록 할 생각입니다. 리스너 입장에서는 더 이상 ArrayList를 받지 않습니다. 그렇다면 뭘 받아야할까요?

  • 이벤트 타입 : begin, move, end 등
  • 손가락번호 : 0~5, fingerId에 해당되는 진짜 ID
  • 터치된 좌표 : 현재 이벤트가 발생한 x, y 좌표
  • 시작점으로부터 거리 : begin 했던 위치와의 차이를 계산한 dx, dy 값

이를 반영하여 새로운 델리게이터를 정의합시다.

delegate void listener( string type, int id, float x, float y, float dx, float dy );

이벤트도 손가락별로 재정의해야합니다.

event listener begin0, begin1, begin2, begin3, begin4;
event listener move0, move1, move2, move3, move4;
event listener end0, end1, end2, end3, end4;

준비가 끝났으니 Update를 개조합시다.

//포인트별 begin의 좌표를 기억해둘 배열
private Vector2[] delta = new Vector2[5];

void Update(){
	int count = Input.touchCount;
	if( count == 0 ) return;

	for( int i = 0 ; i < count ; i++ ){
		Touch touch = Input.GetTouch(i);
		int id = touch.fingerId;

		//터치좌표
		Vector2 pos = touch.position;

		//begin이라면 무조건 delta에 넣어주자.
		if( touch.phase == TouchPhase.Began ) delta[id] = touch.position;

		//좌표계 정리
		float x, y, dx, dy;
		x = pos.x;
		y = pos.y;
		if( touch.phase == TouchPhase.Began ){
			dx = dy = 0;
		}else{
			dx = pos.x - delta[id].x;
			dy = pos.y - delta[id].y;
		}

		//상태에 따라 이벤트를 호출하자
		if( touch.phase == TouchPhase.Began ){
			switch( id ){
			case 0: if(begin0!=null) begin0( "begin",id,x,y,dx,dy ); break;
			case 1: if(begin1!=null) begin1( "begin",id,x,y,dx,dy ); break;
			case 2: if(begin2!=null) begin2( "begin",id,x,y,dx,dy ); break;
			case 3: if(begin3!=null) begin3( "begin",id,x,y,dx,dy ); break;
			case 4: if(begin4!=null) begin4( "begin",id,x,y,dx,dy ); break;
			}
		}else if( touch.phase == TouchPhase.Moved ){
			switch( id ){
			case 0: if(move0!=null) move0( "move",id,x,y,dx,dy ); break;
			case 1: if(move1!=null) move1( "move",id,x,y,dx,dy ); break;
			case 2: if(move2!=null) move2( "move",id,x,y,dx,dy ); break;
			case 3: if(move3!=null) move3( "move",id,x,y,dx,dy ); break;
			case 4: if(move4!=null) move4( "move",id,x,y,dx,dy ); break;
			}
		}else if( touch.phase == TouchPhase.Ended ){
			switch( id ){
			case 0: if(end0!=null) end0( "end",id,x,y,dx,dy ); break;
			case 1: if(end1!=null) end1( "end",id,x,y,dx,dy ); break;
			case 2: if(end2!=null) end2( "end",id,x,y,dx,dy ); break;
			case 3: if(end3!=null) end3( "end",id,x,y,dx,dy ); break;
			case 4: if(end4!=null) end4( "end",id,x,y,dx,dy ); break;
			}
		}
	}
}

아까와 달라진점은 개별 이벤트를 fingerId에 맞춰 통보해준다는 점입니다. delta에 Began 타이밍에 맞춰 좌표를 미리 기억해두고 Moved나 Ended에서는 현재 좌표와 뺀 결과를 보내줍니다. 이제 간단해진 새로운 터치 이벤트에 등록하여 사용해봅시다.

void Start(){
	begin0 += onTouch;
	end0 += onTouch;
	move0 += onTouch;
}

void onTouch( string type, int id, float x, float y, float dx, float dy){
	switch( type ){
	case"begin": Debug.Log( "down:"+x+","+y ); break;
	case"end": Debug.Log( "end:"+x+","+y+", d:"+dx+","+dy ); break;
	case"move": Debug.Log( "move:"+x+ ","+y+", d:"+dx+","+dy ); break;
	}
}
// down:30,50
// move:60,60, d:20,10
// end:70,30, d:40,20

자 고작 이 정도로 앞의 예제와 동일한 기능을 낼 수 있게 되었습니다.

 

결론

유니티 시스템이 네이티브로 처리해주지 않는 터치이벤트를 수동으로 구축해봤습니다. 위와 같이 터치이벤트를 구현하면 일단 기본 이벤트가 처리되므로 웹검색에 그렇게 걸려나오던 Ray처리를 하면 됩니다.

void onTouch( string type, int id, float x, float y, float dx, float dy){
	var ray = Camera.main.ScreenPointToRay ( new Vector2( x, y ) );
	RaycastHit hit;
	if( Physics.Raycast ( ray, out hit ) ){
		hit.transform.SendMessage("Selected");
	}
}

일단 이벤트와 같은 기저시스템에 스트레스를 받기싫어서 구축해봤습니다. 전체 소스는 저번과 마찬가지로 아래 있습니다.

http://blog.bsidesoft.com/unity/bside001.txt