ABOUT ME

작은 디테일에 집착하는 개발자

Today
-
Yesterday
-
Total
-
  • [Project/Android] GameActivity 만들기 (Feat. 조이스틱 만들기)
    Daily/Project 2023. 12. 14. 00:27
    728x90

    ⓒ 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님 감사합니다 🙏🏻 🫢 ♥️

     

Designed by Tistory.