그것이 유저를 위해서라면...
게임 멤버십 서비스 플레이오는 모바일 게임을 플레이 하고, 게임 플레이에 대한 보상을 받을 수 있는 앱이다. 앱 특성상 사용 연령대는 어린편이나 게임 시간 측정을 위해 안드로이드에서 오버레이 권한과 앱 사용시간 접근을 허용하는 등 처음 사용법이 다소 어려운 편이다. 그래서 유저 튜토리얼을 추가하여 훈련시켜보는 건 어떨까하는 생각에 ... 앱에서 튜토리얼을 위한 모의 게임을 만들자! 라고 제안했는데 받아들여졌고 결국 그렇게... 허접하지만 미니 게임을 만들게되었다.
이 글에서는 이번에 안드로이드에서 미니 게임을 만들면서 고려했던 문제들을 하나 하나씩 살펴보려한다.
# 어떤 게임?
위 짤이 바로 만든 게임의 화면이다. 노란 새 버디가 유치원에 등교를 하는데, 버디의 부모님인 동그라미와 세모 (프레이와 이오)가 잘가는지 몰래 지켜보지만 버디가 찾아내는! 스토리...라고한다.
하지만 스토리는 중요하지않다...! ㅋㅋㅋ 알아야할 사항은 다음과 같다.
- 8방향 이동
- 랜덤 배치 오브젝트에 닿으면 점수가 오름
- 소스 코드상에서 부르는 명칭
- 노란 새 = player
- 세모랑 동그라미 = apple
- 초록색 잔디 바탕화면 = map
- 동그란 컨트롤러 = controller
# 게임루프
가장 먼저 생각한 점은 게임 루프다. 대중적인 게임 엔진 Unity에서는 Monobehavior를 상속하면 Update()라는 메소드가 매 프레임마다 호출되면서 이동이나 기타 한 프레임마다 일어나는 일을 정의할 수 있게 해준다.
찍먹했던 Unity를 생각하며...뭘 하려고 하거든 안드로이드 액티비티에서도 게임 루프를 만드는게 먼저라고 생각했다.
나는 게임내용을 그리는 SimulationActivity의 onCreate에 다음과 같이 선언하여 게임 루프를 구현해보았다.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
lifecycleScope.launchWhenResumed {
while (viewModel.isGameRunning) {
update()
withContext(Dispatchers.Default) {
viewModel.onUpdate()
delay(1000 / 40) // FPS
}
}
}
}
lifecycleScope.launchWhenResumed
코루틴을 특정 라이프사이클의 상태에서만 실행할 수 있도록 해주는 것이 launchWhen 시리즈인데, Resumed를 붙이게되면 적어도 Lifecycle.State.RESUMED 상태가 되어야 블록내의 코루틴을 실행하며, Lifecycle이 종료될경우 코루틴이 취소(cancel)된다.
이 화면을 벗어나거나, 잠시 전화가 걸려왔을때 게임 프레임이 계속 흘러가는 것을 막기 위해 launchWhenResumed를 사용했다.
* viewModel.isGameRunning = true다. (추후 개발때 원하는 시점에 멈출 수 있도록 손보려고 한다.)
delay(1000/40)
가장 중요한 것은 메인 스레드(UI 스레드)가 막히지 않는 것이다. 그래서 Dispatchers.Default로 다른 쓰레드에서 잠시 멈추고, 그 후에 다시 메인스레드에서 update() 작업을 수행한다. 1초에 40번, FPS=40이된다.
# 메인 스레드는 오직 그리기만 해야한다
Activity에서는 그리기만 처리하고, 연산을 하는 코드는 ViewModel에 깔끔히 분리했다. ViewModel은 Activity 와 생명주기를 같이하기때문에 제어도 훨씬 간편하다. SimulationActivity의 onCreate에서 게임 플레이중 연산에 필요한 길이 정보들을 모두 ViewModel로 넘겨준다.
viewModel.setSizeConfig(
mapWidth = width.toFloat(), mapHeight = height.toFloat(),
playerWidth = ivCharacter.width.toFloat(),
playerHeight = ivCharacter.height.toFloat(),
apple.width.toFloat(), apple.height.toFloat(),
controller.width, controller.height
)
// 맵 사이즈
var mapWidth = 0F
private var mapHeight = 0F
// 플레이어 사이즈
private var playerWidth = 0F
private var playerHeight = 0F
// 사과 사이즈
private var appleWidth = 0F
private var appleHeight = 0F
// 이동 컨트롤러 사이즈
private var controllerWidth = 0
private var controllerHeight = 0
그리는 부분이 필요한 녀석들은 모두 LiveData로 빼주어 Activity에서 Observe하여 다시 그릴 수 있도록 했다.
이를테면 플레이어가 이동하면 ViewModel에서 다음 위치를 계산 후, playerPosition을 업데이트 하는 식이다.
val playerPosition = MutableLiveData(PointF())
val applePosition = MutableLiveData(PointF(-1F, -1F))
val createApple = MutableLiveData(false)
val score = MutableLiveData(0)
SimulationActivity쪽 Observe하는 코드다.
마지막 apple의 좌우를 반전시키는 코드가있는데, apple은 player를 항상 바라보게 하기위해서 저렇게 해뒀다.
viewModel.playerPosition.observe(this, {
binding.ivCharacter.x = it.x
binding.ivCharacter.y = it.y
// 플레이어쪽을 바라보게함
binding.apple.scaleX = if (it.x < binding.apple.x) -1F else 1F
})
# 8방향 컨트롤
사실 처음엔 4방향 컨트롤이었다. 4방향으로 팀원들에게 게임을 보여주니 8방향 컨트롤이 아니어서 처음에 당황하는 모습이 보였다. "진짜 게임도 아닌데 무슨 8방향이야!!" 라고 생각했지만... 밤에 심심해서 8방향이나 만들어보자 해서 만들게 되었다.
8방향은 아래 여덟가지 조합이있는데, 이를 어떻게 하면 좋을까 고민을 하다가...
Unity에서는 어떻게 하는거지?? 싶어서 유튜브에서 유니티 강의를 찾다 힌트를 얻었다.
좌 | 우 | 상 | 하 |
좌상 | 우상 | 좌하 | 우하 |
유튜브 강의 짤인데 나도 유니티를 몰라서 CrossPlatformInputManager가 뭔지는 모른다!
하지만... 잘 읽어보면 가로축으로 컨트롤러가 얼마나 치우쳤냐에 대한 dirX와 세로축 컨트롤러 정보인 dirY 두가지를 입력 받아서 Vector2가 플레이어의 다음 포지션을 계산하는 코드다.
나도 저렇게 구현해보기로 했다.
컨트롤러에서 터치좌표가 데드존보다 더 크면 1, 작으면 -1을 잡는다.
데드존은 콘솔게임을 많이하다보니 알게된 용어라 저렇게 이름 붙여주었는데, 데드존에 위치하면 조작이 되지 않는다. 일반적으로 8방향 플레이를 할때 가만히 있고싶으면 중앙에 위치하기도 하므로 데드존을 적절히 설정해주었다. 데드존이없으면, 중앙에서 치열한 좌표싸움이 시작된다 ㅋㅋㅋㅋ 1px마다 방향이 갈려서 캐릭터가 발광할것이다.
fun processUserInput(action: Int, eventX: Float, eventY: Float) {
when (action) {
MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_UP -> {
userInput = SimulationActivity.Key.Idle
dirX = 0
dirY = 0
}
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_MOVE -> {
val centerX = controllerWidth / 2F
val centerY = controllerHeight / 2F
// direction 판단
dirX = getDir(eventX, centerX)
dirY = getDir(eventY, centerY)
// 플레이어 고개 방향 판단
if (dirX == 1) userInput = SimulationActivity.Key.Right
else if (dirX == -1) userInput = SimulationActivity.Key.Left
}
}
}
private fun getDir(pos: Float, std: Float): Int {
val deadZone = 10
return when {
pos > std + deadZone -> 1
pos > std - deadZone -> 0
else -> -1
}
}
SimulationViewModel의 코드 조각인데, processUserInput은 컨트롤러의 OnTouchListener에서 호출한다.
터치가 취소(ACTION_CANCEL)되거나, 손을 떼면(ACTION_UP) 방향을 중앙으로 설정하고, 플레이어 입력은 Idle한 상태로 만든다.
그리고 터치가 드래그되며 이어지거나(ACTION_MOVE), 터치가 시작되면(ACTION_DOWN) 이동하는 상태로 보게된다.
controller.setOnTouchListener { v, event ->
controllerBall.x = event.x - controllerBall.width / 2
controllerBall.y = event.y - controllerBall.height / 2
viewModel.processUserInput(
event.action,
event.x
event.y
)
return@setOnTouchListener true
}
Activity쪽의 OnTouchListener를 보자! 터치 이벤트는 ViewModel로 보내고, 그와 별개로 터치 좌표를 따라 controllerBall를 움직이고있다.
모바일 게임 컨트롤러를 보면 컨트롤에 따라서 하얗게 내가 터치한부분에 동그라미가 같이 움직이는데, 이녀석을 controllerBall이라고 이름 지어봤다. 이 녀석의 유무에 따라 유저입장에서 조작감 차이가 꽤 났다. 없을땐...답답함...!
자! 그럼 dirX, dirY를 수집했으니 9개의 경우에 수에따라 플레이어 위치만 정해주면된다.
private fun updatePlayerPosition() {
val maxX = mapWidth - playerWidth
val maxY = mapHeight - playerHeight
playerPosition.value?.let { newPosition ->
val speed: Float = if (dirX != 0 && dirY !=0) {
PLAYER_SPEED / NORMALIZED_DIAGONAL
} else {
PLAYER_SPEED.toFloat()
}
newPosition.x = newPosition.x + (speed * dirX)
newPosition.y = newPosition.y + (speed * dirY)
when {
newPosition.x < 0F -> newPosition.x = 0F
newPosition.x > maxX -> newPosition.x = maxX
newPosition.y < 0F -> newPosition.y = 0F
newPosition.y > maxY -> newPosition.y = maxY
}
Logger.e("[ $dirX , $dirY ]newPosition: $newPosition")
playerPosition.postValue(newPosition)
}
}
플레이어가 맵 밖으로 나가는 것을 막기 위해서 maxX와 maxY를 지정했다. 그래서 다음 프레임의 플레이어 위치인 newPosition은 다음과 같이 되어야한다.
0 <= newPosition <= (maxX, maxY)
이 줄을 표현하기 위해서 when절을 저딴식으로...썼는데 가독성이 매우떨어져서 혹시 좋은 아이디어가 있으신 분은 댓글에 적어줬으면 좋겠다. 플레이어의 다음 위치는 현재 위치 + 방향 * 속도를 하게 되면 계산 할 수 있다.
# 대각선 이동 속도
위에서 이미 코드상에 공개되었지만 대각선 이동도 고려해야했다.
자주보는 게임소식 유튜브 청원이라는 분이있는데, 게임 관련 버그소식들에서 개발자의 계산실수로 대각선이동이 빠르게 구현되어서 유저들이 편법으로 사용했다는 얘기를 본적이 있어서 아예 시작할때부터 "나는 대각선 실수 안해야지!" ㅋㅋㅋ생각했다.
점 A, B, C를 비교해보면 피타고라스 정리에 의해 B의 길이가 가장 긺을 알 수 있다.
C처럼 오른쪽으로 움직여서 player.x += 1 을 할때와
A처럼 위로 움직여서 player.y -= 1을 할때,
B처럼 대각선으로 움직여서 player.x += 1, player.y -= 1을 할때 이동하는 길이가 달라진다.
A와 C에 비해 대각선이동인 B는 한 프레임에 루트2(1.414...)배만큼 더 멀리 움직이게되어 속도가 빨라진다.
따라서 대각선 이동(dirX != 0 && dirY !=0)에 대해서는 원래 속도를 루트2로 나누어 속도를 맞춰주었다.
sqrt를 쓰니 코드가 영 안예뻐져서 그냥 1.41412F 상수로 썼다. 그래서 오차는 조금있다...ㅋㅋ 정확함을 위해서는 sqrt를!
val speed: Float = if (dirX != 0 && dirY !=0) {
PLAYER_SPEED / NORMALIZED_DIAGONAL
} else {
PLAYER_SPEED.toFloat()
}
# 충돌하고, 점수따기!
플레이어와 물체가 충돌하면 점수가 올라가야한다. 모습은 새와 동그라미이지만 실상은 네모네모 이미지뷰들끼리 만나는 거다. 충돌감지를 어떻게 해야할까 고민하다... 예전에 해본기억을 더듬어... 결론이 아래 그림과 같이 났는데, 보다 두 물체 중심점 사이의 거리가 짧으면 충돌한것으로 판정하는 것으로 구현했다. *주의할점은 두 물체 다 정사각형이어야 한다는거다.
충돌 = 두 물체 반지름의 길이의 합(초록선 2개의 합) > 두 물체 중심점 사이의 거리(파란선)
private fun updateApple() {
applePosition.value?.let {
if (it.x < 0F && it.y < 0F) {
if (Moment.isTimeElapsed(appleCollectedAt, 3 * 1000)) {
// 사과가 없는 상태에서 먹은지 3초이상 지났다면 사과 생성
createApple.postValue(true)
}
return
} else {
playerPosition.value?.let { player ->
// 사과가있을때는 충돌검사
val appleCenter = PointF(it.x + appleWidth / 2, it.y + appleHeight / 2)
val playerCenter = PointF(player.x + playerWidth / 2, player.y + playerHeight / 2)
// 두 사각형 반지름의 합보다 중심점사이의 거리가 작으면 충돌.
val radiusSum = (appleWidth / 2) + (playerWidth / 2)
val centerDist = appleCenter.calcDist(playerCenter)
if (centerDist < radiusSum) {
// 사과 먹기 처리
appleCollectedAt = System.currentTimeMillis()
score.postValue((score.value ?: 0) + 1)
applePosition.postValue(PointF(-1F, -1F))
}
}
}
}
}
관련 코드는 이거다. 충돌이 나면, Score + 1 이되고, Score를 Observe하면서 Score가 바뀌면 충돌 애니메이션을 재생시킨다. 물체가 깜짝!놀라면서 사라지는 애니메이션이다. 나름...재미를 위해 진동도 넣어주었다.
viewModel.score.observe(this, {
binding.tvScore.text = StringBuilder("Score $it")
playAppleCollisionAnimation()
})
...
private fun playAppleCollisionAnimation() {
with(binding) {
var newImageResId = R.drawable.tutorial_game_io2
if (apple.tag as? Int == R.drawable.tutorial_game_pray1) {
newImageResId = R.drawable.tutorial_game_pray2
}
Glide.with(apple).load(newImageResId).into(apple)
// 깜짝놀라면서 커짐
VibrationUtil.warning(apple.context)
apple.animate()
.setInterpolator(decayingSineWave)
.scaleX(1.1F)
.scaleY(1.1F)
.rotation(5F)
.setDuration(300)
.start()
// 플레이어의 반대방향으로 사라진다
apple.postDelayed({
val direction = if (apple.x < ivCharacter.x) -1 else 1
apple.animate()
.translationX(viewModel.mapWidth * direction)
.setInterpolator(LinearInterpolator())
.start()
}, 450)
}
}
# 플레이어가 이동하는 동안에만 애니메이션 재생
옛날에 초등학생~중학생때 RPG Maker로 게임을 만들었는데, 캐릭터를 움직이게 만들기 위해서 스프라이트를 썼다. 아래 그림과 같은 스프라이트 이미지를 넣어주면 RPG Maker가 알아서 한 프레임씩 재생시키며 캐릭터를 움직이게 해주었다.
이걸...안드로이드에서 해야한다고...?
한 프레임마다..순차적으로 다른 이미지를...?
귀찮고 비효율적일 것 같아 잠시 절망했지만 나는 게임루프 이외에도 메인 쓰레드의 Looper가 있으니 그냥 캐릭터가 움직이는 동안만 애니메이션을 재생하는 방법으로 때웠다. 움직이는 애니메이션은 APNG로 처리했다.
문워크하는 마이클 잭슨 캐릭터를 만들것이 아니라면 플레이어가 왼쪽으로 이동하면 왼쪽을, 오른쪽으로 이동하면 오른쪽을 보도록 처리해줘야한다.
fun update() {
// 움직임 애니메이션
if (viewModel.userInput == Key.Idle) {
if (playerDrawable?.isPaused == false) playerDrawable?.pause()
binding.controllerBall.visibility = View.GONE
} else {
if (playerDrawable?.isPaused == true) playerDrawable?.resume()
binding.controllerBall.visibility = View.VISIBLE
if (viewModel.userInput == Key.Left) {
binding.ivCharacter.scaleX = 1F
} else if (viewModel.userInput == Key.Right) {
binding.ivCharacter.scaleX = -1F
}
}
}
# 회고
평소에 게임을 좋아하고 만들고 싶어서 깨작깨작 시도했던 것들이 도움이 되는 순간이었다. 역시 게임은 의미있는 활동이야!!! 앞으로도 더 열심히, 편한마음으로 게임을 할 수 있었으면 좋겟따.
'프로그래밍 > Android' 카테고리의 다른 글
kotlin dsl 적용중 versionNeededToExtract 오류 해결방법 (0) | 2022.04.09 |
---|---|
[Android Studio] 범블비 Network Inspector 인코딩 깨짐 해결방법 (0) | 2022.03.05 |
[안드로이드] BottomSheetBehavior로 차이 카드 앱 처럼 UI 구성하기 (0) | 2021.12.01 |
adb에서 쉽게 딥링크(Deeplink) 열기 / 인텐트(Intent) 전송 (0) | 2021.11.24 |
[안드로이드] VideoView 소리없이 비디오 재생하기 (0) | 2021.11.11 |