-
[Project/Android] GameActivity 만들기 (Feat. 조이스틱 만들기)Daily/Project 2023. 12. 14. 00:27728x90
ⓒ 2023. 김한슬 all rights reserved. 위와 같이 게임 필드에서는 아래와 같은 로직들이 필요했습니다.
1. 조이스틱을 통해 캐릭터를 움직이고
2. 상하좌우 방향에 맞추어 걷는 듯한 모션을 위해 캐릭터의 모습을 바꿔줘야 한다는 것
3. 정지했을 때에는 정지한 default 모습을 보여야 한다는 것
4. 몬스터와 만났을 때, GameDetailActivity라는 다른 액티비티로 전환됨과 동시에 보유하고 있던 티켓을 사용해야 한다는 것
5. 코인을 먹었을 때에는 코인의 수가 올라가야 한다는 것
이번 블로그 글에서는 조이스틱을 다뤄보도록 하겠습니다.
조이스틱 만들기
안드로이드에서 조이스틱을 만든 다른 사례가 있는지 확인하기 위해 구글 검색을 시작했습니다.
찾고 찾던 와중, 아래와 같은 블로그 글을 발견했죠.
출처 : 구글 '안드로이드 스튜디오 코틀린 조이스틱' 검색 시, 이미지 탭 정말 ... monotics님의 블로그 글은 제가 찾던 것과 같았습니다.
단순히 강도에 따라 방향을 조절할 뿐만 아니라, 조이스틱을 당기는 정도에 따라 강도를 나타낸다니...
딱 제가 원하던 코드였죠.
안드로이드 조이스틱 뷰 만들기
조이스틱은 흔히 PlayStation, XBox 등의 콘솔 게임에서 캐릭터의 이동 방향을 조정하기 위해 사용된다. 그 외에도 우리 생활 주변에서 다양한 용도로 사용되고 있다. 여러 기능을 갖춘 복잡한 조이
monotics.tistory.com
"커스텀 뷰에 해당하는 별도의 라이브러리 모듈을 프로젝트 밖에 만들고, 나의 프로젝트에 추가"하는 것에 대해 알게 되었습니다.
1. 나의 Default 프로젝트에서 외부 라이브러리 모듈 만들기
(1) File → New → New Module... 순서대로 클릭합니다.
(2) 좌측 메뉴 탭 중 "Android Library" 클릭 및 Module 이름 및 언어, SDK를 설정합니다. (아래와 같이 설정해도 무관)
(3) 아래와 같이 joystick 모듈이 프로젝트 내에 생성된 것을 확인할 수 있습니다.
(4) 먼저 Default 프로젝트의 app 모듈에서 사용하기 위한 작업을 진행합니다.
① settings.gradle.kts 파일에 아래와 같은 내용을 추가합니다.
include(":joystick")
② build.gradle.kts (:app) 파일에 아래와 같은 내용을 추가합니다.
implementation(project(":joystick"))
2. Joystick 모듈 만들기
(1) 재사용을 위한 attr 정의하기
위 본문에 따르면 외부에서 커스텀 뷰를 사용할 때, attr을 정의함으로써 보다 자유롭게 사용할 수 있다고 합니다.
attr이란?
attr은 안드로이드 앱에서 사용자 커스텀 뷰나 레이아웃에서 속성(Attribute)을 정의하고 관리하기 위한 일종의 명세서입니다.
여러 가지 속성을 한 곳에 모아 놓고, 이를 재사용 가능한 형태로 만들기 위해 attr을 사용합니다.① joystick 모듈을 열면, 초기에는 아래와 같은 형태가 나올텐데요.
② joystick → 마우스 우클릭 → New Android Resource Directory
③ 기본적으로 아래와 같이 나올텐데, 그대로 확인 버튼 클릭합니다.
④ values 마우스 우클릭 → New → Android Resource File
⑤ File name을 attrs로 설정한 뒤, 확인 버튼을 클릭하면
⑥ 아래와 같은 형태 만들어진 것을 확인할 수 있습니다.
⑦ attrs.xml의 파일은 아래와 같은 내용을 재사용하도록 구성하였습니다.
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="Joystick"> <attr name="joystickOuterColor" format="color" /> <attr name="joystickInnerColor" format="color" /> <attr name="joystickOuterRatio" format="fraction" /> <attr name="joystickInnerRatio" format="fraction" /> <attr name="joystickUseSpring" format="boolean" /> </declare-styleable> </resources>
○ joystickOuterColor : 조이스틱의 바깥(테두리) 원 색상 지정
● joystickInnerColor : 조이스틱의 이너(스틱) 원 색상 지정
◎ joystickOuterRatio : 조이스틱 뷰에서 테두리가 차지하는 비율 지정
⊙ joystickInnerRatio : 조이스틱 뷰에서 스틱이 차지하는 비율 지정
∽ joystickUseSpring : 스틱을 놓았을 경우, 스틱이 중앙으로 돌아올지(스프링과 같이 돌아오는지) 지정(2) 조이스틱 뷰를 생성해보도록 하죠.
① joystick → java → com.three.joystick에서 마우스 우클릭 → New → Kotlin Class/File
② JoyStickView 라는 이름의 kt 파일을 생성합니다.
3. Joystick 뷰 작성하기
아래 더보기 버튼을 눌러 전체 코드를 확인하세요.
더보기package com.three.joystick import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.util.AttributeSet import android.view.MotionEvent import android.view.View import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.pow import kotlin.math.sin import kotlin.math.sqrt class JoystickView(context: Context?, attrs: AttributeSet?): View(context, attrs), Runnable { // 조이스틱 그리기 private val outerPaint: Paint = Paint() private val innerPaint: Paint = Paint() // 조이스틱 색상 private var outerColor: Int = 0 private var innerColor: Int = 0 // 조이스틱 움직임 감지 리스너 private var moveListener: OnMoveListener? = null // 조이스틱 릴리즈 시 스프링 효과를 사용할지 여부를 나타내는 플래그 private var useSpring = true // 조이스틱 움직임 업데이트를 위한 스레드 private var mThread = Thread(this) // 조이스틱 움직임 업데이트 간격 (밀리초) private var moveUpdateInterval = DEFAULT_UPDATE_INTERVAL // 조이스틱 x, y 위치 private var mPosX: Float = 0.0f private var mPosY: Float = 0.0f // 조이스틱 중심 x, y 위치 private var mCenterX: Float = 0.0f private var mCenterY: Float = 0.0f // 조이스틱의 내부, 외부 원의 비율과 반지름 private var innerRatio = 0.0f private var outerRatio = 0.0f private var innerRadius = 0.0f private var outerRadius = 0.0f init { // XML에서 정의된 사용자 지정 속성을 얻기 위한 코드 context?.theme?.obtainStyledAttributes( attrs, R.styleable.Joystick, 0, 0 )?.apply { try { outerColor = getColor(R.styleable.Joystick_joystickOuterColor, Color.YELLOW) innerColor = getColor(R.styleable.Joystick_joystickInnerColor, Color.BLUE) innerRatio = getFraction(R.styleable.Joystick_joystickInnerRatio, 1, 1, 0.25f) outerRatio = getFraction(R.styleable.Joystick_joystickOuterRatio, 1, 1, 0.75f) useSpring = getBoolean(R.styleable.Joystick_joystickUseSpring, true) } finally { recycle() } outerPaint.isAntiAlias = true outerPaint.color = outerColor outerPaint.style = Paint.Style.FILL innerPaint.isAntiAlias = true innerPaint.color = innerColor innerPaint.style = Paint.Style.FILL } } // View 크기 변경 시, 호출 함수 override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) // 초기 조이스틱, 중심 위치 설정 mPosX = (width / 2).toFloat() mPosY = (width / 2).toFloat() mCenterX = mPosX mCenterY = mPosY // 조이스틱의 내부, 외부 반지름 계산 val d = w.coerceAtMost(h) innerRadius = d / 2 * innerRatio outerRadius = d / 2 * outerRatio } // 터치 이벤트 처리 함수 override fun onTouchEvent(event: MotionEvent?): Boolean { mPosX = event!!.x mPosY = event.y when (event.action) { MotionEvent.ACTION_DOWN -> { // 스레드 실행 중, 중단 및 새로 시작 if (mThread.isAlive) mThread.interrupt() mThread = Thread(this) mThread.start() moveListener?.onMove(getAngle(), getStrength()) } MotionEvent.ACTION_UP -> { // 스레드 중단 및 스프링 효과 적용 mThread.interrupt() if (useSpring) { mPosX = mCenterX mPosY = mCenterY moveListener?.onMove(getAngle(), getStrength()) } } } // 조이스틱이 외부 원을 벗어나지 않도록 조정 val length = sqrt((mPosX - mCenterX).pow(2) + (mPosY - mCenterY).pow(2)) if (length > outerRadius) { // length:radius = (mPosX - mCenterX):new mPosX // length:radius = (mPosY - mCenterY):new mPosY mPosX = (mPosX - mCenterX) * outerRadius / length + mCenterX mPosY = (mPosY - mCenterY) * outerRadius / length + mCenterY } // 화면 다시 그리기 요청 invalidate() return true } // View 그리는 함수 override fun onDraw(canvas: Canvas) { canvas?.drawCircle((width / 2).toFloat(), (width / 2).toFloat(), outerRadius, outerPaint) canvas?.drawCircle(mPosX, mPosY, innerRadius, innerPaint) } // View 크기 측정 함수 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { // setting the measured values to resize the view to a certain width and height val d: Int = measure(widthMeasureSpec).coerceAtMost(measure(heightMeasureSpec)) setMeasuredDimension(d, d) } // View 크기 측정을 돕는 함수 private fun measure(measureSpec: Int): Int { return if (MeasureSpec.getMode(measureSpec) == MeasureSpec.UNSPECIFIED) { // 경계가 지정되지 않은 경우 기본 크기 반환 (200) DEFAULT_SIZE } else { // 사용 가능한 공간을 채우도록 항상 전체 크기 반환 MeasureSpec.getSize(measureSpec) } } // 조이스틱 각도 계산 함수 private fun getAngle(): Int { val xx = mPosX - mCenterX val yy = mCenterY - mPosY val angle = Math.toDegrees(atan2(yy, xx).toDouble()).toInt() // 수평선 아래는 음수 값 계산 return if (angle < 0) angle + 360 else angle } // 조이스틱 강도 계산 함수 private fun getStrength(): Int { val length = sqrt((mPosX - mCenterX).pow(2) + (mPosY - mCenterY).pow(2)) return (length / outerRadius * 100).toInt() } // 주기적으로 실행되는 스레드 override fun run() { while (!Thread.interrupted()) { post { moveListener?.onMove(getAngle(), getStrength()) } try { Thread.sleep(moveUpdateInterval.toLong()) } catch (e: InterruptedException) { break } } } // 조이스틱 움직임 리스너 설정 함수 (리스너 및 업데이트 간격) fun setOnMoveListener(listener: OnMoveListener, intervalMs: Int) { moveListener = listener moveUpdateInterval = intervalMs } // 조이스틱 움직임 리스너 설정 fun setOnMoveListener(listener: OnMoveListener) { setOnMoveListener(listener, DEFAULT_UPDATE_INTERVAL) } // 조이스틱 움직임 리스너 인터페이스 fun interface OnMoveListener { fun onMove(x: Int, y: Int) } // 상수 및 태그 companion object { private val TAG = JoystickView::class.java.simpleName private const val DEFAULT_SIZE = 200 private const val DEFAULT_UPDATE_INTERVAL = 50 } // 조이스틱 각도 설정 함수 fun setAngle(angle: Int) { val radian = Math.toRadians(angle.toDouble()) // 각도와 현재 조이스틱의 강도(반지름)를 사용하여 새로운 조이스틱 위치를 계산 val newX = mCenterX + getStrength() / 100f * outerRadius * cos(radian) val newY = mCenterY - getStrength() / 100f * outerRadius * sin(radian) updateJoystickPosition(newX.toFloat(), newY.toFloat()) } // 조이스틱 강도 설정 함수 fun setStrength(strength: Int) { // 강도를 비율로 변환 (0 ~ 100 -> 0 ~ 1) val ratio = strength / 100f // 새로운 조이스틱 위치를 계산하고 업데이트 val newX = mCenterX + (mPosX - mCenterX) * ratio val newY = mCenterY + (mPosY - mCenterY) * ratio updateJoystickPosition(newX, newY) } // 조이스틱 위치 업데이트 함수 private fun updateJoystickPosition(newX: Float, newY: Float) { mPosX = newX mPosY = newY // 화면 다시 그리기 요청 invalidate() } }
(1) 클래스 선언 및 변수 초기화
JoyStickView 클래스는 View 클래스를 확장하며, Runnable 인터페이스를 구현합니다.
조이스틱의 모양과 위치, 움직임 등을 관리하는 여러 변수를 선언합니다.
class JoystickView(context: Context?, attrs: AttributeSet?): View(context, attrs), Runnable { // 조이스틱 그리기 private val outerPaint: Paint = Paint() private val innerPaint: Paint = Paint() // 조이스틱 색상 private var outerColor: Int = 0 private var innerColor: Int = 0 // 조이스틱 움직임 감지 리스너 private var moveListener: OnMoveListener? = null // 조이스틱 스프링 효과를 사용할지 여부를 나타내는 플래그 private var useSpring = true // 조이스틱 움직임 업데이트를 위한 스레드 private var mThread = Thread(this) // 조이스틱 움직임 업데이트 간격 (밀리초) private var moveUpdateInterval = DEFAULT_UPDATE_INTERVAL // 조이스틱 x, y 위치 private var mPosX: Float = 0.0f private var mPosY: Float = 0.0f // 조이스틱 중심 x, y 위치 private var mCenterX: Float = 0.0f private var mCenterY: Float = 0.0f // 조이스틱의 내부, 외부 원의 비율과 반지름 private var innerRatio = 0.0f private var outerRatio = 0.0f private var innerRadius = 0.0f private var outerRadius = 0.0f ... }
(2) 초기화 블록 `init`
초기화 블록은 객체가 생성될 때 초기 설정을 진행합니다.
XML에서 정의된 사용자 지정 속성을 읽어와, 조이스틱의 색상이나 비율, 스프링의 사용 여부 등을 설정합니다.
init { // XML에서 정의된 사용자 지정 속성을 얻기 위한 코드 context?.theme?.obtainStyledAttributes( attrs, R.styleable.Joystick, 0, 0 )?.apply { // 미지정 시, 기본 설정되는 속성 try { outerColor = getColor(R.styleable.Joystick_joystickOuterColor, Color.YELLOW) innerColor = getColor(R.styleable.Joystick_joystickInnerColor, Color.BLUE) innerRatio = getFraction(R.styleable.Joystick_joystickInnerRatio, 1, 1, 0.25f) outerRatio = getFraction(R.styleable.Joystick_joystickOuterRatio, 1, 1, 0.75f) useSpring = getBoolean(R.styleable.Joystick_joystickUseSpring, true) } finally { recycle() } outerPaint.isAntiAlias = true outerPaint.color = outerColor outerPaint.style = Paint.Style.FILL innerPaint.isAntiAlias = true innerPaint.color = innerColor innerPaint.style = Paint.Style.FILL } }
isAntiAlias (안티 앨리어싱, Anti-aliasing : 그래픽의 가장자리를 부드럽게 처리)
조이스틱의 원이 픽셀화된 모양 없이 더 매끄럽게 보이도록 합니다.
color (페인트 색상)
여기서는 외부 원과 내부 원에 각각 다른 색상을 적용합니다.
style (페인트 스타일)
Paint.Style.FILL은 채우기 모드로, 원의 내부를 색상으로 채웁니다.(3) onSizeChanged 메서드
*뷰의 크기가 변경될 때마다 호출되는 것 으로,
조이스틱의 중심 위치와 내부/외부 원의 반지름을 계산합니다.
*뷰의 크기가 변경된다는 것은 뷰가 처음 생성될 때, 레이아웃이 변화할 때(화면 회전, 뷰의 크기 조정)를 의미합니다.
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) // 초기 조이스틱, 중심 위치 설정 mPosX = (width / 2).toFloat() mPosY = (width / 2).toFloat() mCenterX = mPosX mCenterY = mPosY // 조이스틱의 내부, 외부 반지름 계산 val d = w.coerceAtMost(h) innerRadius = d / 2 * innerRatio outerRadius = d / 2 * outerRatio }
w, h
뷰의 너비와 높이
mPosX, mPosY
조이스틱의 현재 위치 (초기에는 뷰의 가운데 위치)
mCenterX, mCenterY
조이스틱의 중심점 위치
w.coerceAtMost(h)
조이스틱을 원형으로 유지하기 위해 너비와 높이 중 더 작은 값을 선택
innerRadius, outerRadius
조이스틱의 내부 원과 외부 원의 반지름, 각각 내부/외부 비율에 따라 계산(4) onTouchEvent 메서드
사용자의 터치 이벤트를 처리하는 함수로, 조이스틱의 위치를 업데이트하고 조이스틱이 외부 원을 벗어나지 않도록 조정합니다.
터치 이후, 움직임에 따라 조이스틱의 위치를 업데이트하고, 터치 다운, 터치 업에 따라 다른 동작을 수행합니다.
override fun onTouchEvent(event: MotionEvent?): Boolean { mPosX = event!!.x mPosY = event.y when (event.action) { MotionEvent.ACTION_DOWN -> { // 누르고 있을 경우, 스레드 실행 중 중단 및 새로 시작 if (mThread.isAlive) mThread.interrupt() mThread = Thread(this) mThread.start() moveListener?.onMove(getAngle(), getStrength()) } MotionEvent.ACTION_UP -> { // 떼고 있을 경우, 스레드 중단 및 스프링 효과 적용 mThread.interrupt() if (useSpring) { mPosX = mCenterX mPosY = mCenterY moveListener?.onMove(getAngle(), getStrength()) } } } // 조이스틱이 외부 원을 벗어나지 않도록 조정 val length = sqrt((mPosX - mCenterX).pow(2) + (mPosY - mCenterY).pow(2)) if (length > outerRadius) { mPosX = (mPosX - mCenterX) * outerRadius / length + mCenterX mPosY = (mPosY - mCenterY) * outerRadius / length + mCenterY } // 화면 다시 그리기 요청 invalidate() return true }
(5) onDraw 메서드
조이스틱을 화면에 그리는 메서드로, Canvas 객체를 사용해 조이스틱의 외부 원과 내부 원을 그립니다.
override fun onDraw(canvas: Canvas) { canvas?.drawCircle((width / 2).toFloat(), (width / 2).toFloat(), outerRadius, outerPaint) canvas?.drawCircle(mPosX, mPosY, innerRadius, innerPaint) }
Canvas와 Paint의 차이
Canvas
도화지. 다양한 그래픽을 그릴 수 있는 표면을 제공
캔버스 객체를 사용하여 직선, 원, 사각형 등 다양한 그래픽과 텍스트를 그릴 수 있다.
캔버스에 그리는 작업은 해당 캔버스에 연결된 비트맵 또는 화면에 직접 반영된다.
Paint
붓과 물감. 색상, 스타일, 글꼴, 크기 등 그림을 그릴 때 사용되는 속성을 정의
예를 들어, 캔버스에 원을 그리려면, 페인트 객체를 사용하여 원의 색상, 선의 굵기, 스타일 등을 지정한다.
즉, 캔버스는 그래픽이 그려지는 면적이고, 페인트는 그 표면에 그려질 그림의 특성을 정의하는 데 사용한다.(6) onMeasure 메서드
뷰의 크기를 측정하고 설정하는 메서드로, 뷰의 크기를 결정하는 데 사용됩니다.
// View 크기 측정 함수 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val d: Int = measure(widthMeasureSpec).coerceAtMost(measure(heightMeasureSpec)) setMeasuredDimension(d, d) } // View 크기 측정을 돕는 함수 private fun measure(measureSpec: Int): Int { return if (MeasureSpec.getMode(measureSpec) == MeasureSpec.UNSPECIFIED) { // 경계가 지정되지 않은 경우 기본 크기 반환 (200) DEFAULT_SIZE } else { // 사용 가능한 공간을 채우도록 항상 전체 크기 반환 MeasureSpec.getSize(measureSpec) } } // 상수 companion object { ... // 부모 레이아웃이 특정 크기를 지정하지 않는 경우, 조이스틱 뷰 200X200(px) private const val DEFAULT_SIZE = 200 ... }
widthMeasureSpec, heightMeasureSpec
부모 레이아웃으로부터 전달받은 너비와 높이
coerceAtMost
원형으로 유지하기 위해 너비와 높이 중 더 작은 값을 선택
setMeasuredDimension
뷰의 크기를 최종 설정(7) getAngle, getStrength 메서드
조이스틱의 각도와 강도를 계산하는 메서드입니다.
특히, 강도는 조이스틱이 중심으로부터 얼마나 멀리 떨어져 있는지를 통해 나타냅니다.
private fun getAngle(): Int { val xx = mPosX - mCenterX val yy = mCenterY - mPosY val angle = Math.toDegrees(atan2(yy, xx).toDouble()).toInt() // 수평선 아래는 음수 값 계산 return if (angle < 0) angle + 360 else angle } private fun getStrength(): Int { val length = sqrt((mPosX - mCenterX).pow(2) + (mPosY - mCenterY).pow(2)) return (length / outerRadius * 100).toInt() }
(8) 스레드를 실행하는 `run`
Runnable 인터페이스를 구현하는 부분으로, 지속적으로 조이스틱의 움직임을 감지하고 리스너에게 데이터를 전달합니다.
override fun run() { while (!Thread.interrupted()) { post { moveListener?.onMove(getAngle(), getStrength()) } try { Thread.sleep(moveUpdateInterval.toLong()) } catch (e: InterruptedException) { break } } }
(9) 상수
// 상수 및 태그 companion object { private val TAG = JoystickView::class.java.simpleName private const val DEFAULT_SIZE = 200 private const val DEFAULT_UPDATE_INTERVAL = 50 }
⭐️ (10) 조이스틱 리스너 설정과 조작을 위한 메서드
아주 중요한 부분인데요. 뒤에서 충돌 감지 함수를 위해 새롭게 추가한 메서드들입니다.
몬스터와 만났을 때, 캐릭터의 동작을 멈추기 위해서는 조이스틱의 각도와 강도를 0으로 만들어야 하기 때문에 추가하였습니다.
// 조이스틱 각도 설정 함수 fun setAngle(angle: Int) { val radian = Math.toRadians(angle.toDouble()) // 각도와 현재 조이스틱의 강도(반지름)를 사용하여 새로운 조이스틱 위치를 계산 val newX = mCenterX + getStrength() / 100f * outerRadius * cos(radian) val newY = mCenterY - getStrength() / 100f * outerRadius * sin(radian) updateJoystickPosition(newX.toFloat(), newY.toFloat()) } // 조이스틱 강도 설정 함수 fun setStrength(strength: Int) { // 강도를 비율로 변환 (0 ~ 100 -> 0 ~ 1) val ratio = strength / 100f // 새로운 조이스틱 위치를 계산하고 업데이트 val newX = mCenterX + (mPosX - mCenterX) * ratio val newY = mCenterY + (mPosY - mCenterY) * ratio updateJoystickPosition(newX, newY) } // 조이스틱 위치 업데이트 함수 private fun updateJoystickPosition(newX: Float, newY: Float) { mPosX = newX mPosY = newY // 화면 다시 그리기 요청 invalidate() }
빨간색 점은 조이스틱의 중심점. 초록색 점은 새로 계산된 조이스틱 위치. 검은색 선은 중심점과 조이스틱 위치 사이의 연결선.
① setAngle, 각도 설정 함수
① - 1. 각도를 라디안 단위로 변환합니다.
val radian = Math.toRadians(angle.toDouble())
① - 2. 새로운 조이스틱 위치(newX, newY)는 다음과 같이 계산됩니다.
newX = mCenterX + getStrength() / 100 * outerRadius * cos(radian)
newY = mCenterY - getStrength() / 100 * outerRadius * sin(radian)
조이스틱의 중심점으로부터 각도와 강도에 따라 새 위치를 결정합니다.② setStrength, 강도 설정 함수
② - 1. 강도(strength)는 백분율로 입력되며, 이를 0에서 1 사이의 비율로 변환합니다.
val ratio = strength / 100f
② - 2. 새로운 조이스틱 위치는 다음과 같이 계산됩니다.
newX = mCenterX + (mPosX - mCenterX) * ratio
newY = mCenterY + (mPosY - mCenterY) * ratio
조이스틱의 현재 위치(mPosX, mPosY)에서 중심점까지의 거리를 강도에 따라 조정하여 새 위치를 결정합니다.③ updateJoystickPosition 도우미 메서드
updateJoystickPosition은 조이스틱의 위치를 업데이트하는 데 사용하는 함수입니다.
사용자의 입력(터치) 또는 setAngle, setStrength와 같은 변경에 의해 호출됩니다.
새로운 위치를 계산한 후에 invalidate(무효화)하여, 화면을 다시 그리고 사용자 인터페이스를 업데이트합니다.
4. 프로젝트의 xml에서 Joystick View 사용하기
(1) xml 파일에 Joystick을 불러옵니다.
<com.three.joystick.JoystickView android:id="@+id/joystick" android:layout_width="180dp" android:layout_height="180dp" android:layout_margin="10dp" app:joystickInnerColor="@color/white" app:joystickOuterColor="@color/transparent" app:joystickUseSpring="true" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" />
attrs에 설정했던 속성들 중
joystickInnerColor, joystickOuterColor, joystickUseSpring를
이너는 하얀색, 아우터는 불투명한 하얀색, 스프링 효과는 사용으로 설정하였고,
joystickInnerRatio와 joystickOuterRatio는
default 값인 0.25f와 0.75f를 사용해주기 위해 추가 설정하지 않았습니다.5. 프로젝트의 kt 파일에서 xml 파일의 조이스틱 사용하기
아래 더보기 버튼을 눌러 전체 코드를 확인하세요.
더보기class GameActivity : AppCompatActivity() { ... private lateinit var joystickView: JoystickView ... override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_game) ... joystickView = findViewById(R.id.joystick) ... setJoystick() ... } override fun onResume() { super.onResume() isStop = false joystickView.setAngle(0) joystickView.setStrength(0) joystickView.setOnMoveListener { angle, strength -> if (!isStop) { updateCharacterImage(angle, strength, charImageView, gameModel.characterImages) moveCharacter(angle, strength, screenWidth, screenHeight, charImageView) checkCoinIntersect(charImageView) checkMonsterIntersect(charImageView) } } } ... /** 조이스틱 버튼 설정하는 함수 */ private fun setJoystick() { joystickView.setOnMoveListener { angle, strength -> if (!isStop) { updateCharacterImage(angle, strength, charImageView, gameModel.characterImages) moveCharacter(angle, strength, screenWidth, screenHeight, charImageView) checkMonsterIntersect(charImageView) checkCoinIntersect(charImageView) } } } /** 조이스틱 각도에 따라 캐릭터 이미지 변경하는 함수 */ private fun updateCharacterImage( angle: Int, strength: Int, charImageView: ImageView, userImages: List<String> ) { val imageResource = when { (angle >= 315 || angle < 45) -> if (strength == 0) userImages[9] else getWalkingImage( userImages[10], userImages[11] ) (angle >= 45 && angle < 135) -> if (strength == 0) userImages[0] else getWalkingImage( userImages[1], userImages[2] ) (angle >= 135 && angle < 225) -> if (strength == 0) userImages[6] else getWalkingImage( userImages[7], userImages[8] ) (angle >= 225 && angle < 315) -> if (strength == 0) userImages[3] else getWalkingImage( userImages[4], userImages[5] ) else -> prevImageResource } // 조이스틱 멈췄을 때 이전 상태의 기본 이미지 유지 if (strength == 0) { loadImageFromUrl(getPreviousDirectionImage(prevAngle, userImages), charImageView) } else { loadImageFromUrl(imageResource, charImageView) prevAngle = angle prevImageResource = imageResource } } /** 캐릭터 걷는 동작 설정하는 함수 */ private fun getWalkingImage(image1: String, image2: String): String { // 번갈아 가며 이미지 반환 walkingCounter = (walkingCounter + 1) % WALKING_SPEED return if (walkingCounter < 5) image1 else image2 } /** 이전 상태 방향의 기본 이미지 반환하는 함수 */ private fun getPreviousDirectionImage(angle: Int, userImages: List<String>): String { val direction = when { (angle >= 315 || angle < 45) -> userImages[9] (angle >= 45 && angle < 135) -> userImages[0] (angle >= 135 && angle < 225) -> userImages[6] (angle >= 225 && angle < 315) -> userImages[3] else -> userImages[3] } return direction } /** 캐릭터 움직이는 함수 */ private fun moveCharacter( angle: Int, strength: Int, screenWidth: Int, screenHeight: Int, charImageView: ImageView ) { // 조이스틱의 각도를 라디안으로 변환 val radian = Math.toRadians(angle.toDouble()) // 조이스틱의 각도에 맞게 x, y 방향을 계산 val moveX = strength * MOVE_FACTOR * Math.cos(radian).toFloat() val moveY = strength * MOVE_FACTOR * -Math.sin(radian).toFloat() // 현재 캐릭터의 위치 val currentX = charImageView.x val currentY = charImageView.y // 이동 후 위치 계산 val newX = currentX + moveX val newY = currentY + moveY // 화면 경계 내에 위치하도록 제한 val clampedX = newX.coerceIn(0f, (screenWidth - charImageView.width).toFloat()) val clampedY = newY.coerceIn(0f, (screenHeight - charImageView.height).toFloat()) // "charImageView" 이미지 뷰의 위치 변경 charImageView.x = clampedX charImageView.y = clampedY } /** 몬스터와 만났을 때 처리 담당하는 함수 */ private fun checkMonsterIntersect(charImageView: ImageView) { for (monsterView in monsterViews) { val charRect = Rect() val monsterRect = Rect() charImageView.getHitRect(charRect) monsterView.getHitRect(monsterRect) if (Rect.intersects(charRect, monsterRect)) { isStop = true joystickView.setAngle(0) joystickView.setStrength(0) loadImageFromUrl( getPreviousDirectionImage(prevAngle, gameModel.characterImages), charImageView ) val monsterId = monsterView.tag as String val intent = Intent(this, GameDetailActivity::class.java).apply { putExtra("MONSTER_ID", monsterId) putExtra("USER_ID", gameModel.userId) } startActivity(intent) gameViewModel.handleTicketIntersect(gameModel) setTicketAmount(gameModel.ticketAmount) monsterView.visibility = View.GONE monsterViews.remove(monsterView) break } } } /** 코인과 만났을 때 처리 담당하는 함수 */ private fun checkCoinIntersect(charImageView: ImageView) { for (coinView in coinViews) { val charRect = Rect() val coinRect = Rect() charImageView.getHitRect(charRect) coinView.getHitRect(coinRect) if (Rect.intersects(charRect, coinRect)) { gameViewModel.handleCoinIntersect(gameModel) setCoinAmount(gameModel.coinAmount) coinView.visibility = View.GONE coinViews.remove(coinView) break } } } }
(1) 초기화와 설정
① 변수 선언 및 초기화
joystickView는 JoystickView 타입으로 선언되며, lateinit을 통해 나중에 초기화됩니다.
② onCreate 설정
setContentView를 호출하여 레이아웃을 설정하고,
findViewById를 통해 XML 레이아웃 파일에서 JoystickView 인스턴스를 찾아 joystickView에 할당합니다.
또한 setJoystick 함수를 호출하여 조이스틱의 움직임 리스너를 설정합니다.class GameActivity : AppCompatActivity() { private lateinit var joystickView: JoystickView ... override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_game) joystickView = findViewById(R.id.joystick) setJoystick() ... } }
(2) 조이스틱 리스너 설정
각도와 강도를 통해 게임 캐릭터의 이미지를 걷는 것과 같이 업데이트하고, 위치를 이동시킵니다.
또한 몬스터와 코인과 충돌했는지 확인합니다.
/** 조이스틱 버튼 설정하는 함수 */ private fun setJoystick() { joystickView.setOnMoveListener { angle, strength -> if (!isStop) { updateCharacterImage(angle, strength, charImageView, gameModel.characterImages) moveCharacter(angle, strength, screenWidth, screenHeight, charImageView) checkMonsterIntersect(charImageView) checkCoinIntersect(charImageView) } } }
(3) 그 외 게임 상호작용
이 부분은 어떤 코드를 작성하는지에 따라 달라질 것 같습니다.
① 저는 조이스틱의 각도에 따라 캐릭터의 이미지를 상하좌우 방향에 맞는 이미지로 변경해야 하고 → updateCharacterImage
② 조이스틱에 따라 캐릭터의 x, y 좌표를 업데이트하여 실제로 화면 상에서 이동시켜야 하며 → moveCharacter
③ 캐릭터가 몬스터나 코인과 충돌했는지 확인해야 했습니다. → checkMonsterIntersect, checkCoinIntersect
따라서 아래와 같은 함수들이 추가적으로 필요했죠!
private fun updateCharacterImage( angle: Int, strength: Int, charImageView: ImageView, userImages: List<String> ) { ... } private fun moveCharacter( angle: Int, strength: Int, screenWidth: Int, screenHeight: Int, charImageView: ImageView ) { ... } private fun checkMonsterIntersect(charImageView: ImageView) { ... } private fun checkCoinIntersect(charImageView: ImageView) { ... }
현재 private으로 작업되고 있는 프로젝트라... (ㅠㅠ) 전체 코드가 필요하신 분은 댓글 부탁드립니다.
휴... 이만 이번 블로그 글은 마치도록 하겠습니다.
긴 글 읽어주셔서 감사하며... 다시 한 번 monotics님 감사합니다 🙏🏻 🫢 ♥️
'Daily > Project' 카테고리의 다른 글
[개인 프로젝트] 내가 사용하기 위한 앱 만들기 (Feat. 집중을 위한 앱) (1) 2024.05.12 [Project/Android] `OutOfMemoryError` 오류 해결하기 (0) 2023.12.19 [Project/Android] `도전! 환경 지킴 방범대` 프로젝트 1 (Feat. 소개) (0) 2023.12.12