본문 바로가기
프로젝트

[1인 게임 개발]Baseball Swing!

by 블로그별명 2024. 10. 7.

Baseball Swing!은 유니티를 독학하고 처음 개발한 게임입니다. 유니티를 선택한이유는 저는 모바일 게임을 개발해 광고수익을 얻으려는 목적으로 게임 개발을 시작하기로 마음 먹었고, 현재 모바일게임 개발에 대중적으로 쓰이는 엔진이 유니티였기 때문이었습니다.

저는 게임 개발에 처음 입문하는것을 고려하려 비교적 개발 난이도가 낮은 하이퍼 캐쥬얼 게임을 개발하기로 결정했습니다. 유니티 독학기간 포함, 프로젝트는 6월 7일에 시작되어 8월 21에 끝났습니다.

 

 

코드: https://github.com/hangilzzang/Baseball-Swing-

게임링크(플레이 스토어): https://play.google.com/store/apps/details?id=com.baseballswing.gilgames

 

Baseball Swing! - Google Play 앱

공을 던지고 스윙하세요!

play.google.com

 


 

스프라이트 에셋 제작

게임에 적합한 스프라이트 에셋을 구하기 어려워 직접 제작하기로 결정했습니다. 그러나 저는 이러한 아트 경험이 전혀 없어, 제작 난이도가 상대적으로 낮은 픽셀아트 형식으로 만들기로 했습니다. 

 

공던지기, 사람, 야구공

 

공던지기 애니메이션 구성

타자가 공을 위로 던집니다. 이후 두손으로 야구배트를 잡고 공을 칠 준비를 합니다.

 

공을 위로 던지는 모션의 구현

애니메이션 이벤트를 이용하여 야구공 애니메이션의 마지막 프레임에서 BallGoesUp 함수를 호출합니다.

// ThrowBall.cs
void BallGoesUp()
{
    ballRigidbody.WakeUp(); //rigidbody 컴포넌트 활성화
    ballRigidbody.AddForce(Vector2.up * throwForce); 
}

 

스위트 스팟으로 공을 낙하시키기

야구에서는 이상적인 타격영역인 스위트 스팟이 존재합니다.

 

그러나 공이 수직낙하 하는 지점은 스위트 스팟이 아닌 배트의 손잡이와 가까운쪽입니다. 따라서 공이 화면밖에서 최고점에 도달했을때 공의 X축 위치를 바꿔 스위트 스팟쪽으로 떨어질수있게 구현했습니다. 물론 공을 정확하게 스위트 스팟에 떨어지는 각도로 던지도록 구현하는것이 가장 자연스럽지만 구현의 어려움으로 포기했습니다.

 

파워 게이지 구현

마우스를 클릭해 움직이는 움직이는 파워게이지를 멈출수있습니다. 파워게이지는 타자가 공을 위로 던지는 힘에 영향을 줍니다. => 이후에 yoyo형 애니메이션으로 수정됩니다.

 

update문에서 애니메이션의 진행정도를 정규화하여 파워게이지값으로 이용합니다. PowerGauge 게임 상태에서 클릭 입력이 들어오면 파워게이지 애니메이션을 정지시킵니다. 

// PowerGaugeController.cs
void Update()
{
    if (animator.enabled)
    {
        GameManager.instance.powerValue = animator.GetCurrentAnimatorStateInfo(0).normalizedTime % 1f; // 애니메이션의 진행정도에 다라 게이지값 할당 0~1범위

        if (GameManager.instance.gameState == GameManager.GameState.PowerGauge && Input.GetMouseButtonDown(0))
        {
            [파워게이지에 따라 타자가 공을 위로 던진다.]
        }        
    }
}

 

 

파워 게이지값의 활용

파워게이지 애니메이션을 중지한뒤 타자가 공을 위로 던지는 애니메이션을 실행합니다. 파워게이지값이 높을수록 던지는 애니메이션의 속도 그리고 이후 공에 가해지는 힘을 더 크게 세팅합니다.

// PowerGaugeController.cs
if (GameManager.instance.gameState == GameManager.GameState.PowerGauge && Input.GetMouseButtonDown(0)) // 파워게이지 게임 상태일때 클릭 입력 발생시
{
    animator.speed = 0; // 파워게이지 애니메이션 중지
    
    GameManager.instance.gameState = GameManager.GameState.NotGettingAnyInput; // 게임 상태 변경
    
    GameManager.instance.powerValue = animator.GetCurrentAnimatorStateInfo(0).normalizedTime % 1f; // 애니메이션의 진행정도에 따라 게이지값 할당, 0~1범위
    GameManager.instance.CalculateThrowForce();
    player.PlayThrowBallAnimation();
    ball.PlayBallAnimation();
}
// GameManger.cs
public void CalculateThrowForce() 
{
    throwForce = Mathf.Lerp(minThrowForce, maxThrowForce, PowerValue);
    animationSpeed = Mathf.Lerp(minAnimationSpeed, maxAnimationSpeed, PowerValue);
}

 

 

 

클릭시 스윙 애니메이션 재생

 

클릭할때마다 스윙애니메이션이 재생되도록 구현하였습니다.

 

그러나 저는 스윙 애니메이션 재생 도중에 클릭 입력이 들어왔을때 현재 재생중인 스윙 애니메이션을 캔슬하고 다시 재생되지 않기를 원했습니다. 따라서 스윙을 진행할때 어떠한 입력도 받지않는 상태로 게임상태를 바꾸고 마지막 스윙 프레임에서 다시 스윙 입력을 받을수있는 상태로 전환하여 문제를 해결했습니다.

// PlayerController.cs
if (GameManager.instance.gameState == GameManager.GameState.BatSwing && Input.GetMouseButtonDown(0))
{
    animator.SetBool("Swing", true);
    GameManager.instance.gameState = GameManager.GameState.NotGettingAnyInput;
}
// PlayerController.cs
void SwingAvailable() // 애니메이션 이벤트로부터 실행
{
    GameManager.instance.gameState = GameManager.GameState.BatSwing;
}

 

 

여기까지의 구현으로 모든 플레이어 애니메이션이 게임에 적용되었습니다. 그나저나 잠깐 플레이어의 애니메이터 컨트롤러에 대해 잠시 설명하고 넘어가면 좋을것같습니다.

 

간단하게 볼을 던지는 파트와 스윙을 하는 애니메이션 두개의 파트로 구분되어있습니다.

ThrowBall과 ThrowBall2를 구분한 이유는, ThrowBall은 공을 던지는 모션, ThrowBall2는 던진팔을 내리고 타격자세를 잡는 애니메이션입니다.

ThrowBall2는 ThrowBall과 다르게 파워게이지의 값에 영향을 받지않는 방향으로 구현하고싶었기때문에(공을 더 쎄게던졌다고 더 빠르게 타격 자세를 잡는것은 오히려 부자연스럽다고 생각했습니다.) 공을 던지는 과정을 두개로 분리했습니다.

Swing1은 타격폼에서 공을 치기까지의 애니메이션 Swing2는 공을 친상태에서 다시 타격폼으로 돌아가는 애니메이션입니다.

만약 공을 치게 된다면 Swing1에서 타격이펙트 쪽으로 넘어가게되고 못친경우 헛스윙으로 Swing2애니메이션이 재생되게 됩니다.

아직 타격 이벤트를 구현하지는 않았지만 미리 스윙 애니메이션을 두개로 분리해놨다고 보시면 될것같습니다.

 

 

타격 정확도 출력

공이 떨어지는쪽에 적절하게 타자의 트리거 콜라이더를 배치해줍니다.

해당 트리거콜라이더는 공의 콜라이더와 완전히 일치하는 모양 + 같은 Y축에 배치되어있기때문에, 공이 떨어지면서 타자의 트리거 콜라이더와 완전히 겹치는 순간이 존재합니다.

Swing1애니메이션 마지막프레임에 호출되는 함수를 수정하여, 해당 타이밍에 두 콜라이더가 얼마나 겹쳤는지를 계산하고, 이를 타격 정확도로 삼았습니다.

// PlayerController.cs
void PlaySwing2Animation () // 애니메이션 이벤트로부터 실행
{
    float accuracy = CalculateHitAccuracy();

    if (accuracy == 0)
    {
        animator.SetBool("Swing", false);
    }

    Debug.Log("SwingAccuracy: " + accuracy);
}

 

 

점수 계산

타격정확도와 파워게이지값을 종합하여 최종적인 점수를 계산하는식을 구현해야합니다.

타격정확도와 파워게이지값이 각각 만점에 가까워질수록 점수가 기하급수적으로 오르도록 구현하고싶었기때문에 지수함수를 사용하여 이런식으로 구현해봤습니다.

 

\( \text{scoreValue} = \text{minScore} + (\text{maxScore} - \text{minScore}) \times \frac{\text{gradient}^{(\text{powerValue} \times \text{accuracyValue})} - 1}{\text{gradient} - 1} \)

 

// GameManager.cs
    public void CalculateScore()
{   
    float value = Mathf.Pow(gradient, (powerValue * accuracyValue)); // 점수의 급등을 지수함수의 형태로 구현
    float normalizedValue = Normalize(value, 1, gradient); // 정규화
    scoreValue = (int)(minScore + (maxScore - minScore) * normalizedValue);
}

// 주어진 값을  0과 1사이의 값으로 정규화
float Normalize(float value, float min, float max)
{
    return (value - min) / (max - min);
}

 

해당 코드만 부분테스트해보며 적절한 gradient값을 찾을수 있었습니다.

추가적으로 배트로 공을 맞췄을때는 공의 움직임을 멈추어 유저가 타격정확도를 확인할수있게 했습니다.

 

 

결과 씬 제작

따라서 결과씬은 이전씬으로부터 현재 점수정보를 가져와야하기때문에, 따라서 기존 GameManager 스크립트에 DontDestroyOnLoad(gameObject); 코드를 추가하여 씬 전환시 파괴되지 않도록 설정하였습니다.

 

장면 전환 효과 제작

타격이후 결과창으로 즉시 넘어갈시 어색하다고 느껴져, 타격과 동시에 하얀빛으로 물들어가면서 다음장면으로 넘어가는 효과를 추가하였습니다.

 

화면전체를 덮는 하얀색 이미지의 투명도를 공 타격시 3초에 걸쳐 완전히 낮추는 방식으로 구현했습니다. 이후 결과씬으로 이동합니다.

 

메인화면 제작

메인화면 ⇒ 게임화면 ⇒ 결과화면 ⇒ 메인화면

으로 이어지도록 게임을 구현할 예정입니다. 순환하는 구조이기때문에 게임의 구조가 단순하고, 구현에 큰 어려움이 없을것으로 예상됩니다.

약간의 문제가 있었던 부분은 메인화면에서 게임화면으로 넘어가는 부분이었는데, `touch to start` 문구에 따라 화면 터치시 게임이 시작됨과 동시에 파워게이지 클릭도 동시에 이루어져, 원하지않았던 방향으로 게임이 진행되었습니다.

문제는 메인화면에서 게임화면으로 넘어갈때 기존 `Input.GetMouseButtonDown(0)`  에서 `Input.GetMouseButtonUp(0)` 입력을 받는것으로 수정함으로서 해결되었습니다.

 

결과화면2 제작

공이 바닥에 닿았을시 타격을 실패한것으로 간주하고

- 게임 상태를 `GameRestart` 로 전환: 
재시작 터치 입력을 기다리게 됩니다.
- `Touch To Restart`  UI 활성화

// ThrowBall.cs
void OnCollisionEnter2D(Collision2D collision) // 공이 땅과 접촉시 실행
{
    ballOnGround = true;
}

void Update()
{
    if (ballOnGround == true && GameManager.instance.gameState == GameManager.GameState.BatSwing)
    {
        GameManager.instance.gameState = GameManager.GameState.GameRestart;
        restartText.SetActive(true);       
    }
}

 

장면 전환 효과 제작(공 줍기)

결과화면2는 공을 치지못해 공이 바닥에 떨어져있는 모습입니다. 그리고 메인화면은 다시 공을 들고 서있는 모습입니다. 터치할경우 결과화면2에서 메인화면으로 전환되는데 아무래도 캐릭터의 자세가 바로 바뀌다보니 어색한 느낌이 들었습니다. => 이후 touch to restart를 누를필요없이 자동으로 떨어진 공을 줍도록 수정되었습니다.

 

따라서 땅에 떨어진 공을 줍는 애니메이션을 추가하여 이러한 부자연스러움을 없애고자 하였습니다.

 

터치 입력을 받으면

  • 땅에떨어진 공은 비활성화됩니다 ⇒ 공을 줍는 애니메이션에 공이 포함되어있기때문
  • 어떠한 입력도 받지않는 상태가 됩니다.
  • 애니메이션은 트리거에 의해 실행됩니다. ⇒ 어떠한 스윙도 하지않았을때는 ThrowBall2 상태이고 스윙을 한번 이상한 상태에서는 Swing1 상태이기때문에 두 상태에서 모두 공줍는 애니메이션으로 전이되기 위해서는 anystate의 활용이 필요했습니다.
// TouchToRestartUI.cs
void Update()
{
    if (GameManager.instance.gameState == GameManager.GameState.GameRestart && Input.GetMouseButtonUp(0))
    {
        GameManager.instance.gameState = GameManager.GameState.NotGettingAnyInput; // 애니메이션 재생될동안 어떠한 입력도받지않기
        gameObject.SetActive(false); // UI비활성
        ball.SetActive(false);
        powerGauge.SetActive(false);
        animator.SetTrigger("PickUp"); 
    }
}

 

여기까지의 과정으로 게임이 플레이가능해졌습니다.

 

 

효과음 제작

게임을 플레이해보면서 소리가 필요하다고 느낀부분에 대해, `효과음을 직접 제작` or `법적 문제없는 효과을 다운`  or `AI툴을 이용해 제작` 그리고 이후 `오디오 후편집`을 통해 효과음을 제작했습니다. 

 

인게임 광고 구현

점수를 얻는데 도움이 될 만한 아이템을 보상형 광고 시청 보상으로 주도록 구현할 생각입니다.

구현할 보상형 광고는 총 두가지 입니다.

  • 보상: 기존 나무배트 ⇒ 알루미늄 배트로 변경 최종 점수가 n% 증가합니다.
  • 보상: 기존 공 ⇒ 작은 낙하산이 달린 공으로 변경 공이 떨어지는 속도가 n% 감소합니다.

 

UI제작 및 배치

 

 

 

 

UI 터치 구현

  • 터치시 체크표시가 포함된 UI로 변경됩니다
  • 터치시 버튼기능이 비활성화 됩니다.
// AluminumUI.cs 
// UmbrellaUI.cs
void Start()
{
    uiButton.onClick.AddListener(OnButtonClicked);
}

void OnButtonClicked()
{
    uiImage.sprite = checkUISprite;
    uiButton.interactable = false;
}

 

다만 이경우 UI를 클릭할때 기존 게임에서의 클릭입력 처리(TouchToStart)와 혼선이 생겨 UI를 클릭하면 게임이 시작되는 문제가 발생했습니다.

우선 기존에 간단하게 터치입력으로 동작하던 TouchToStart를 ⇒ 전체화면 크기의 UI를 클릭하면 동작하도록 수정했습니다(버튼 컴포넌트 이용).

 

// TouchToStartUI.cs
public void GameStart() // 버튼 클릭 이벤트로 호출되는 메서드
{
    if (GameManager.instance.gameState == GameManager.GameState.GameStart)
    {
		    ...   
        GameManager.instance.gameState = GameManager.GameState.PowerGauge; // 게임상태 변경
        RewardADUI.SetActive(false); // 광고UI 컨테이너 비활성화
    }
}

 

그리고 기존에 구현했던 UI는 새로운 캔버스(기존 캔버스보다 상위 레이어)에 부착함으로서 광고 UI를 클릭했을때는 게임이 시작되지않도록 했습니다.

`EventSystem.current.IsPointerOverGameObject()` 를 활용하는 방법도 있었으나 이것을 이용할경우 구현이 복잡해지고 무엇보다도 터치 입력을 처리하도록 코드를 작성해야했기때문에 에디터에서 더이상 실행할수 없게됩니다. 따라서 해당방법은 이용하지않았습니다.

 

 

UI 클릭후 보상 구현

admob광고를 유저에게 보여주기 위해서는 먼저 admob광고를 로드하는 과정이 필요합니다.

광고로드는 즉시 이루어지지않기때문에 광고를 보여주기전 미리 로드해놓는 과정이 필요합니다.

 

나무배트 ⇒ 알루미늄 배트 보상

  • 인게임 나무 배트 애니메이션이 모두 알루미늄 배트 애니메이션으로 대체됩니다.
  • 타격음이 알루미늄 배트 타겸음으로 변경됩니다.
  • 최종 게임점수가 n%증가합니다.

BaseballSwing!의 모든 애니메이션은 2D 스프라이트를 순차적으로 재생하는 방식의 애니메이션이기때문에 애니메이션속 나무 배트를 알루미늄 배트로 대체하는방식으로 구현할수없었습니다.

따라서 기존 애니메이션 컨트롤러 에셋과 똑같지만 알루미늄 베트를 들고있는 클립으로 구성된 애니메이션 컨트롤러 에셋을 추가적으로 만들었습니다.

플레이어 게임오브젝트의 애니메이터 컴포넌트를 바꿔줌으로서 나무 배트에서 알루미늄 배트로의 변경이 가능합니다.

// AluminumUI.cs 
void OnButtonClicked()
{
    ...
    playerAnimator.runtimeAnimatorController = newController; // 플레이어 애니메이션 알루미늄배트 애니메이션으로 변경
    GameManager.instance.aluminumBat = true; // 최종점수에 영향을 미친다
    playerController.batHitClip = newBatHitClip; // 배트 소리 변경 
}

 

 

야구공 => 낙하산이 달린 야구공 보상

  • 공이 떨어지기 시작할때 공 스프라이트를 낙하산이 달린 공 스프라이트로 교체합니다.
  • 공의 추락 속도를 조절합니다.

위에 낙하산이 달린 공 스프라이트를 만들었습니다. 공이 최고점에 도달했을때 낙하산이 달린 공 스프라이트로 변경됩니다. 또한 리지드바디컴포넌트의 gravity scale을 조절해 더 천천히 떨어지도록 수정했습니다.

// ThrowBall.cs
void MoveToSweetSpot()
{
    Vector2 newPosition = new Vector2(sweetSpotPosition.x, ballRigidbody.position.y);
    ballRigidbody.position = newPosition;

    if (GameManager.instance.parachuteBall) // 낙하산일경우
    {
        animator.enabled = false; // 스프라이트 변경을위해 애니메이션 중지
        spriteRenderer.sprite = newSprite;
        ballRigidbody.gravityScale = GameManager.instance.parachuteBallAdvantage; // 중력 스케일 조절
    }
}

 

광고 UI와 admob 연결

이전의 과정들로 UI 클릭 ⇒ 보상 획득을 구현했다면 이제 광고 로드 완료 ⇒ UI 터치 ⇒ 광고재생 ⇒ 보상 획득 을 구현해보겠습니다.

 

광고로드가 완료되면 광고 UI를 활성화 시킵니다.

// TouchToStartUI
void Start()
{
            ...
    // 광고가 모두 준비되었으면 광고 UI활성화
    if (RewardADAluminum.instance._rewardedAd != null && RewardADParachute.instance._rewardedAd != null)
    {
        RewardADUI.SetActive(true);
    }             
}

 

여기서 생각해봐야 할부분은 광고로드는 코드 실행후 어느정도 시간이 소요된다는 점입니다. (테스트결과 대략 4~5초정도 걸리는것같습니다.) 따라서 이를 고려해 플레이어가 원활하게 플레이를 하기위해서는 게임 시작 화면 이전에 미리 광고를 로드해놀 필요가 있었습니다.

게임이 재시작될때 다시 결과씬에서 게임씬으로 돌아가서 사이클을 반복하기때문에, 이에 영향받지 않기위해 광고 스크립트를 싱글톤 패턴으로 구현했습니다.

 

광고로드의 정확한 시점은 다음과 같습니다.

baseball swing!의 리워드 광고 UI는 게임시작전 대기창 에서만 팝업됩니다. 따라서 게임이 시작된 이후 바로 광고를 로드하면 다음 게임 시작전까지 광고를 미리 준비할수있습니다.

문제는 최초로 게임을 실행했을때는 광고를 로드할 시간이 전혀 주어지지않는다는것입니다. 따라서 이를위해 게임 인트로씬을 따로 만들었습니다. 인트로씬 도중 광고를 로드할수있게말이죠.

 

 

 

기존 광고 스크립트에 광고 재생 메서드 추가

public void ShowAd()
{
    if (_rewardedAd != null && _rewardedAd.CanShowAd())
    {
        _rewardedAd.Show((Reward reward) => {});

    }
}

 

광고 버튼 클릭되었을때 광고 재생하도록 코드 수정

void OnButtonClicked()
{
    RewardADParachute.instance.ShowAd();
    ...
}

 

이렇게 광고요소가 결합된 최종적인 게임이 완성되었습니다.

'프로젝트' 카테고리의 다른 글

[1인 게임 개발]Cannon vs Cannon  (0) 2024.10.07

댓글