본문 바로가기
Development/Internship

[멋사 로켓단 인턴쉽] 8일차 - Stamp Effect 및 Result Effect

by Mobics 2025. 8. 18.

목차


    멋쟁이사자처럼 로켓단 인턴쉽

    25.08.18

    회의록

    >> 안건

    • 업무 업데이트
      • 질문 혹은 피드백 요청
    • 주요 안건
      • 앱등록을 위한 게임 제목 정하기
      • 완성목표개발 지속
    • 향후 마일스톤
      • 디테일 작업, 버그수정
      • 출시심사준비

     

    >> 회의 내용

    • 씬 한개로 통합작업 예정
    • 게임 제목 --> 도장 쾅쾅으로 확정
      • 도장찍기
      • 결재의 달인
      • 도장 찍기 : 부장님 승인해주세요!
      • 만나서 결재
      • 결재내역
      • 결재도장

    ※ GPT가 짜준 제목

    • 간단하고 직관적인 제목:
      • 도장 쾅쾅 (Stamp Stamp)
      • 서류 대작전
      • 결재왕
      • 도장 레이스
      • 빠른 결재
    • 게임성을 강조한 제목:
      • 스피드 스탬프
      • 서류 러시
      • 결재 마스터
      • 도장 챌린지
      • 승인 거부 (OK/NO)
    • 시간 압박을 나타내는 제목:
      • 마감 도장
      • 긴급 결재
      • 시한 스탬프
      • 데드라인 오피스
    • 재미있는 말장난 제목:
      • 도장각 (도장+긴장감)
      • 결재의 달인
      • 스탬프 서바이벌

     

    Stamp Effect 버그 수정

    : 내가 코드를 짜는 동안 다른 팀원들이 만들어 둔 것들을 내 Branch로 Merge한 다음 테스트해보니 새로운 버그가 발생했다.

    --> 도장이 현재 보여지는 서류가 아니라 다음 서류에 찍히는 버그

     

    >> 버그 원인 분석

    : 이전에 게임을 재시작하면 게임의 진행이 막히는 현상이 있었고, 이를 다른 팀원 분께서 수정하셨다. 이때, 바꾼 재시작 방식에 따라 서류 초기화 로직을 추가하셨는데, 여기서 문제가 발생했다.

    --> 게임 시작 전, 서류 풀을 한번 초기화하는 로직을 추가하셨는데, 서류 풀 초기화 함수에 보면 기존의 서류를 전부 풀에 반환하고 다시 새로운 서류를 만든다. 따라서 내가 만든 도장이 기존 서류가 아닌 새로운 서류에 붙으면서 다음 서류에 나오는 것이다.

    1. ReloadDocument() 호출
    2. 기존 '_docObj'를 풀에 반환하고, '_docObj = null' 한 다음 'CreateDocument()'를 호출하여 다음 서류 생성
    3. 근데 내가 만든 기존의 ShowStamp()는 '_docObj'를 기준으로 도장 Sprite를 붙임
    4. 따라서 이미 새 서류가 '_docObj'에 할당된 상태라 다음 서류에 도장이 붙음

     

    - 서류 초기화 함수

    : DocumentController.ReloadDocument()

    <hide/>
    public void ReloadDocument(bool noLoop = false)
    {
        if (_docObj != null)
        {
            // 자식 오브젝트들을 먼저 풀에 반환
            for (int i = _docObj.transform.childCount - 1; i >= 0; i--)
            {
                var child = _docObj.transform.GetChild(i).gameObject;
                DocumentPool.Instance.ReturnObject(child);
            }
    
            // 마지막에 서류 자체 반환
            DocumentPool.Instance.ReturnObject(_docObj);
        }
    
        _currentObstacles.Clear();
        _obstacleObjs.Clear();
        _rejectObj = null;
        _docObj = null;
        GameManager.Instance.GetClassification().obstacle = false;
    
        if(!noLoop) CreateDocument();
    }

     

    >> 버그 해결 과정

    1. ShowStamp에서 '_docObj'가 아니라 지금 화면에 있는 서류를 참조하도록

    : 승인 또는 반려 버튼을 눌러서 ShowStamp()가 호출됐을 때의 서류를 지역 변수로 캐싱하여 그 서류에 도장 Sprite를 붙이도록 구현

    <hide/>
    public void ShowStamp(bool isApproved)
    {
        // 현재 보여지고 있는 서류를 지역 변수로 캐싱
        GameObject targetDoc = _docObj;
        if (targetDoc == null) return;
    
        GameObject prefab = isApproved ? approvalStampPrefab : deniedStampPrefab;
        GameObject currentStamp = Instantiate(prefab, targetDoc.transform, false);
    
        currentStamp.transform.localPosition = new Vector2(1f, -2f);
    
        // TODO: 도장 찍히는 연출
    }

     

    ▶ 여전히 동일한 문제가 발생한다.

    --> 승인 또는 반려 버튼 쪽 함수들을 보니 서류 분류 함수를 호출한 다음 도장 찍는 함수와 도장 사운드 함수를 호출하는데, 서류 분류 메소드의 끝에서 ReloadDocument()를 호출하여 기존 서류를 반환하고 새 서류를 만드는 것을 발견

    <hide/>
    public void OnClickAcceptButton()
    {
        GameManager.Instance.GetClassification().confirm = true; //승인버튼 클릭시 서류 승인
        GameManager.Instance.GetClassification().DocumentClassification(); // 서류 분류 메소드 호출
        AudioManager.Instance.SFX.PlayStamp();
        GameManager.Instance.GetDocumentController().ShowStamp(true);
    }

     

    2. 도장을 찍는 순서를 변경

    : 도장을 먼저 찍고 서류 분류 메소드를 호출하여 ShowStamp()에서 지금 화면에 있는 서류를 참조하도록

    <hide/>
    public void OnClickAcceptButton()
    {
        GameManager.Instance.GetClassification().confirm = true; //승인버튼 클릭시 서류 승인
        AudioManager.Instance.SFX.PlayStamp();
        GameManager.Instance.GetDocumentController().ShowStamp(true);
        GameManager.Instance.GetClassification().DocumentClassification(); // 서류 분류 메소드 호출
    }

     

    ▶ 이제는 도장이 아예 안 찍혀서 나오는 문제가 발생. --> Console을 보니 기존에 없던 경고가 나왔다.

    --> DocumentController의 ReloadDocument()를 보면 DocumentPool.cs에 있는 ReturnObject()를 통해 오브젝트를 반환하는데, ReturnObject()를 보면 프리팹 정보를 못 찾았을 때 경고를 출력하도록 되어있다. 그리고 이 경고가 Console에 나온 경고와 같았다. 즉, 도장 프리팹이 서류 오브젝트의 자식으로 생성되지만, ReturnObject()를 통해 곧바로 Stamp까지 같이 반환/삭제가 되며 그때 Stamp의 경우에는 OriginalPrefab 정보를 못 찾아서 경고와 함께 삭제되는 것

     

    - DocumentPool.cs

    <hide/>
    // 오브젝트 반환 메서드 (비활성화 후 풀에 넣기)
    public void ReturnObject(GameObject obj)
    {
        obj.SetActive(false);
    
        // 오브젝트에 붙어 있는 스크립트에서 원래의 프리팹 정보 가져오기
        var obstacleController = obj.GetComponent<ObstacleController>();
        var rejectController = obj.GetComponent<RejectController>();
        var document = obj.GetComponent<Document>();
    
        GameObject prefabKey = null;
    
        if (obstacleController != null)
            prefabKey = obstacleController.OriginalPrefab;
        else if (rejectController != null)
            prefabKey = rejectController.OriginalPrefab;
        else if (document != null)
            prefabKey = document.OriginalPrefab;
        
        // 프리팹 정보를 못 찾으면 경고 출력 후 오브젝트 삭제
        if (prefabKey == null)
        {
            Debug.LogWarning("ReturnObject: OriginalPrefab not found on object, destroying.");
            Destroy(obj);
            return;
        }
    
        // 해당 프리팹의 큐가 없으면 새로 생성
        if (!poolDictionary.ContainsKey(prefabKey))
            poolDictionary[prefabKey] = new Queue<GameObject>();
    
        poolDictionary[prefabKey].Enqueue(obj);
    }

     

    3. 도장 프리팹은 서류와 함께 Pool에 영향을 받지 않도록 분리

    : ReloadDocument()에서 서류를 Pool에 반환하기 전에, 서류의 자식들 중 Stamp를 찾아서 따로 제거

    --> 도장 프리팹에 Tag로 "Stamp"를 추가 및 지정하여 태그로 Stamp를 구분하도록 구현

     

    - DocumentController.cs

    <hide/>
    public void ReloadDocument(bool noLoop = false)
    {
        if (_docObj != null)
        {
            // 자식 오브젝트들을 먼저 풀에 반환
            for (int i = _docObj.transform.childCount - 1; i >= 0; i--)
            {
                var child = _docObj.transform.GetChild(i).gameObject;
                if (child.CompareTag("Stamp"))
                {
                    Destroy(child);     // 도장 프리팹은 Pool에 영향 받지 않도록 따로 제거
                }
                else
                {
                    DocumentPool.Instance.ReturnObject(child);
                }
            }
    
            // 마지막에 서류 자체 반환
            DocumentPool.Instance.ReturnObject(_docObj);
        }
    
        _currentObstacles.Clear();
        _obstacleObjs.Clear();
        _rejectObj = null;
        _docObj = null;
        GameManager.Instance.GetClassification().obstacle = false;
    
        if(!noLoop) CreateDocument();
    }

     

    ▶ 그럼에도 여전히 도장이 보이지 않음.

    --> 이전에 다른 팀원이 서류의 등장 및 퇴장 연출을 구현해놨었는데, 자세히 보니 서류의 등장 연출만 나오고 퇴장 연출이 나오지 않고 있었다. 서류의 퇴장 연출은 DocumentController.cs의 RemoveDocument() 함수로, 서류의 퇴장 연출이 끝난 뒤에 ReloadDocument()를 호출하여 서류를 풀에 반환하도록 되어있다. 즉, 현재 도장이 보이지 않는 것이 아니라 도장이 찍힘과 동시에 퇴장 연출 없이 곧바로 ReloadDocument()가 호출되어 서류 및 도장이 반환/제거되어 우리 눈에는 보이지 않았던 것이다.

     

    - DocumentController.cs

    <hide/>
    // 서류 치우기 함수
    public void RemoveDocument()
    {
        _isClickable = false;
        
        //서류 퇴장 연출
        _docObj.transform.DOMove(new Vector3(_docObj.transform.position.x, _docDespawnY, _docObj.transform.position.z), _duration)
            .SetEase(Ease.Linear)
            .OnComplete(() =>
            {
                _docObj.transform.DOMove(new Vector3(_docDespawnX, _docObj.transform.position.y, _docObj.transform.position.z), _duration)
                    .SetEase(Ease.Linear)
                    .OnComplete(() => ReloadDocument());
            });
    }

     

    4. 서류 퇴장 연출 복구

    : 승인 또는 반려 버튼을 눌렀을 때, 도장이 찍힌 다음 서류 분류 메소드가 호출되고 서류 분류 메소드의 끝에서 ReloadDocument()가 아니라 RemoveDocument()를 호출하여 서류 퇴장 연출을 보인 다음 서류를 반환하도록 구현

     

    - Classification.cs

    <hide/>
    public void DocumentClassification() //서류 분류 메소드
        {
            float time = GameManager.Instance.GetTimeController()._remainedTimerTime; // 남은 일과시간
            int day = GameManager.Instance.GetTimeController()._day; // 남은 진행일수
            // float time = TimeController.Instance._remainedTime; // 남은 일과시간
            // int day = TimeController.Instance._day; // 남은 진행일수
    
            if (obstacle) // 장애물이 있을 때 
            {
                //success = false;
                time -= 5 * day; //일과시간 감소
                combo = 0; //콤보 초기화
                feverValue -= (float)(feverValue * 0.1); //피버 게이지 감소
                scoreMagnification(); //점수 배율 적용
                UpdateScoreMagUI(); //점수 배율 UI 갱신
                UpdateComboUI();
                UpdateFeverUI(); //피버 게이지 UI 갱신
                Debug.Log("분류 실패! 장애물 있음. 일과시간 감소: " + time + ", 현재 콤보: " + combo + ", 최대 콤보: " + maxCombo + "점수 배율: " + scoreMag + "피버게이지: " + feverValue);
            }
            else // 장애물이 없을 때
            {
                if (clean) // 반려요소가 없을 때
                {
                    if (confirm) // 승인 버튼 클릭 시
                    {
                        //success = true;
                        time += 1 * day; //일과시간 증가
                        combo += 1; //콤보 증가
                        if (!fever)
                            feverValue += 3 * scoreMag;
                        score += (int)((1 * day) * scoreMag); //점수 증가
                        scoreMagnification(); //점수 배율 적용
                        UpdateScoreUI(); //점수 UI 갱신
                        UpdateScoreMagUI(); //점수 배율 UI 갱신
                        UpdateComboUI();
                        UpdateFeverUI(); //피버 게이지 UI 갱신
                        if (combo > maxCombo)
                        {
                            maxCombo = combo; //최대 콤보 갱신
                        }
                        if (feverValue >= 100) // 피버 게이지가 100 이상일 때
                        {
                            fever = true; // 피버 상태로 변경
                            feverValue = 0; // 피버 게이지 초기화
                            Debug.Log("피버 상태 진입! 현재 콤보: " + combo + ", 최대 콤보: " + maxCombo);
                        }
                        Debug.Log("분류 성공! 일과시간 증가: " + time + ", 현재 콤보: " + combo + ", 최대 콤보: " + maxCombo + "점수 배율: " + scoreMag + "피버게이지: " + feverValue);
                    }
                    else // 반려 버튼 클릭 시
                    {
                        //success = false;
                        time -= 5 * day; //일과시간 감소
                        combo = 0; //콤보 초기화
                        feverValue -= (float)(feverValue * 0.1); //피버 게이지 감소
                        scoreMagnification(); //점수 배율 적용
                        UpdateScoreMagUI(); //점수 배율 UI 갱신
                        UpdateComboUI();
                        UpdateFeverUI(); //피버 게이지 UI 갱신
                        Debug.Log("분류 실패! 일과시간 감소: " + time + ", 현재 콤보: " + combo + ", 최대 콤보: " + maxCombo + "점수 배율: " + scoreMag + "피버게이지: " + feverValue);
                    }
                }
                else // 반려요소가 있을 때
                {
                    if (confirm) // 승인 버튼 클릭 시
                    {
                        //success = false;
                        time -= 5 * day; //일과시간 감소
                        combo = 0; //콤보 초기화
                        feverValue -= (float)(feverValue * 0.1); //피버 게이지 감소
                        scoreMagnification(); //점수 배율 적용
                        UpdateScoreMagUI(); //점수 배율 UI 갱신
                        UpdateComboUI();
                        UpdateFeverUI(); //피버 게이지 UI 갱신
                        Debug.Log("분류 실패! 반려요소 있음. 일과시간 감소: " + time + ", 현재 콤보: " + combo + ", 최대 콤보: " + maxCombo + "점수 배율: " + scoreMag + "피버게이지: " + feverValue);
                    }
                    else // 반려 버튼 클릭 시
                    {
                        //success = true;
                        time += 1 * day; //일과시간 증가
                        combo += 1; //콤보 증가
                        if (!fever)
                            feverValue += 3 * scoreMag;
                        score += (int)((1 * day) * scoreMag); //점수 증가
                        scoreMagnification(); //점수 배율 적용
                        UpdateScoreUI(); //점수 UI 갱신
                        UpdateScoreMagUI(); //점수 배율 UI 갱신
                        UpdateComboUI();
                        UpdateFeverUI(); //피버 게이지 UI 갱신
                        if (combo > maxCombo)
                        {
                            maxCombo = combo; //최대 콤보 갱신
                        }
                        if (feverValue >= 100) // 피버 게이지가 100 이상일 때
                        {
                            fever = true; // 피버 상태로 변경
                            feverValue = 0; // 피버 게이지 초기화
                            Debug.Log("피버 상태 진입! 현재 콤보: " + combo + ", 최대 콤보: " + maxCombo);
                        }
                        Debug.Log("분류 성공! 반려요소 없음. 일과시간 증가: " + time + ", 현재 콤보: " + combo + ", 최대 콤보: " + maxCombo + "점수 배율: " + scoreMag + "피버게이지: " + feverValue);
                    }
                }
            }
    
            GameManager.Instance.GetTimeController().SetRemainedTimer(time); // 남은 일과시간 갱신
            GameManager.Instance.GetTimeController().SetDay(day); // 남은 진행일수 갱신
            docController.RemoveDocument(); // 서류 재생성
        }

     

    ▶ 드디어 도장이 정상적으로 현재 보이는 서류 위에 찍히는 것을 확인

     

    └ 최종 코드

    - DocumentController.cs

    <hide/>
    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.Serialization;
    using DG.Tweening;
    using UnityEngine.UI;
    using Random = UnityEngine.Random;
    
    public class DocumentController : MonoBehaviour
    {
        //서류 프리팹
        [SerializeField] private GameObject _documentPrefab;
        //반려 요소 프리팹 (0:낙서, 1:잉크번짐, 2:커피 쏟음, 3:물쏟음, 4:인쇄불량)
        [SerializeField] private List<GameObject> _rejectObjPrefabs;
        //장애물 프리팹 (0:날벌레, 1:포스트 잇, 2:요구자 손, 3:파일철, 4:서류봉투)
        [SerializeField] private List<ObstacleData> _obstacleObjDatas;
        
        //서류 및 장애물 데이터
        private DocumentData _currentDocument;
        private List<ObstacleInstance> _currentObstacles = new List<ObstacleInstance>();
        
        //생성된 서류 및 장애물 오브젝트
        private GameObject _docObj;
        private GameObject _rejectObj;
        private List<GameObject> _obstacleObjs = new List<GameObject>();
        
        //서류 사이즈(반려요소 스폰지점 산출에 사용)
        private Vector3 _documentSize;
        
        //날짜 수
        private int _day;
        
        //서류 스폰 포인트
        [SerializeField] private float _docSpawnX;
        [SerializeField] private float _docSpawnY;
        
        //서류 도착 포인트
        [SerializeField] private float _docStopPosX;
        [SerializeField] private float _docStopPosY;
        
        //서류 디스폰 포인트
        [SerializeField] private float _docDespawnX;
        [SerializeField] private float _docDespawnY;
        
        //이동 시간
        [SerializeField] private float _duration;
    
        [Header("도장 관련")]
        [SerializeField] private GameObject approvalStampPrefab;
        [SerializeField] private GameObject deniedStampPrefab;
        
        //버튼 연타 방지용 변수
        [NonSerialized] public bool _isClickable;
        
        public void InitDocuments()
        {
            GameManager.Instance.GetClassification().docController = this;
            _currentObstacles.Clear();
            _obstacleObjs.Clear();
            
            var renderer = _documentPrefab.GetComponent<SpriteRenderer>();
            _documentSize = renderer != null ? renderer.bounds.size : Vector3.one;
            
            CreateDocument();
        }
        
        //서류 타입 결정 함수
        void CreateDocument()
        {
            _currentDocument = new DocumentData();
            
            _currentDocument.documentType = (Random.Range(0, 2) == 0);
            GameManager.Instance.GetClassification().clean = _currentDocument.documentType;
            
            _currentDocument.rejectObjIdx = Random.Range(0, _rejectObjPrefabs.Count);
            
            
            // todo: if 피버타임이라면
            // {
            //     _currentDocument.documentType = true
            // }
            
            // 반려 요소 크기 캐싱
            var rejectRenderer = _rejectObjPrefabs[_currentDocument.rejectObjIdx].GetComponent<SpriteRenderer>();
            
            Vector3 rejectSize = rejectRenderer != null ? rejectRenderer.bounds.size : Vector3.zero;
    
            float minX = -_documentSize.x / 2f + rejectSize.x / 2f;
            float maxX = _documentSize.x / 2f - rejectSize.x / 2f;
            float minY = -_documentSize.y / 2f + rejectSize.y / 2f;
            float maxY = _documentSize.y / 2f - rejectSize.y / 2f;
    
            _currentDocument.spawnPosX = Random.Range(minX, maxX);
            _currentDocument.spawnPosY = Random.Range(minY, maxY);
            
            
            //장애물 타입 결정 함수로
            CreateObstacle();
        }
    
        //장애물 타입 결정 함수
        void CreateObstacle()
        {
            _day = GameManager.Instance.GetTimeController()._day;
    
            int diffiycult = (_day / 5) + 1;
            int obstacleType = Random.Range(0, _obstacleObjDatas.Count);
            
            if (obstacleType == 0 || obstacleType == 1) // 날벌레, 포스트잇
            {
                for (int i = 0; i < diffiycult; i++)
                {
                    var obstacle = new ObstacleInstance();
                    obstacle.obstacleObjIdx = obstacleType;
                    obstacle.prefab = _obstacleObjDatas[obstacleType].obstaclePrefab;
                    obstacle.spawnPos = new Vector2(
                        Random.Range(-_documentSize.x / 2f, _documentSize.x / 2f),
                        Random.Range(-_documentSize.y / 2f, _documentSize.y / 2f)
                    );
                    obstacle.processCount = 1;
                    _currentObstacles.Add(obstacle);
                }
            }
            else if (obstacleType == 2) // 손
            {
                var obstacle = new ObstacleInstance();
                obstacle.obstacleObjIdx = obstacleType;
                obstacle.prefab = _obstacleObjDatas[obstacleType].obstaclePrefab;
                obstacle.spawnPos = new Vector2(1f, -2f);   //로컬 위치 고정 (todo:추후 도장찍는 위치 결정나면 바꿔야함)
                obstacle.processCount = diffiycult;
                _currentObstacles.Add(obstacle);
            }
            else // 서류철, 폴더
            {
                var obstacle = new ObstacleInstance();
                obstacle.obstacleObjIdx = obstacleType;
                obstacle.prefab = _obstacleObjDatas[obstacleType].obstaclePrefab;
                obstacle.spawnPos = Vector2.zero;
                obstacle.processCount = diffiycult;
                _currentObstacles.Add(obstacle);
            }
            
            //서류 생성 함수로
            SpawnDocument();
        }
    
        // 서류 생성 함수
        void SpawnDocument()
        {
            _obstacleObjs.Clear();
            
            // 서류 생성
            _docObj = DocumentPool.Instance.GetObject(_documentPrefab, new Vector3(7, 0.5f, 0), Quaternion.identity);
            _docObj.transform.SetParent(this.transform, true);
    
            // 서류 타입에 따라 반려 요소 생성
            if (!_currentDocument.documentType)
            {
                _rejectObj = DocumentPool.Instance.GetObject(
                    _rejectObjPrefabs[_currentDocument.rejectObjIdx], Vector3.zero, Quaternion.identity
                );
                
                // (부모: 서류 오브젝트)
                _rejectObj.transform.SetParent(_docObj.transform, false);
                _rejectObj.transform.localPosition = new Vector3(
                    _currentDocument.spawnPosX, _currentDocument.spawnPosY, 0f
                );
            }
            
            
            // 확률에 따라 장애물 생성
            float chance = Mathf.Clamp(_day * 5f, 0f, 100f);
            if (Random.Range(0f, 100f) < chance)
            {
                GameManager.Instance.GetClassification().obstacle = true;
    
                foreach (ObstacleInstance obstacle in _currentObstacles)
                {
                    var obj = DocumentPool.Instance.GetObject(obstacle.prefab, Vector3.zero, Quaternion.identity);
                    
                    //(부모: 서류 오브젝트)
                    obj.transform.SetParent(_docObj.transform, false);
                    obj.transform.localPosition = new Vector3(
                        obstacle.spawnPos.x, 
                        obstacle.spawnPos.y, 
                        0f
                    );
    
                    _obstacleObjs.Add(obj);
    
                    var obstacleController = obj.GetComponent<ObstacleController>();
                    if (obstacleController != null)
                    {
                        obstacleController.Initialize(this, obstacle.processCount);
                    }
                }
            }
            
            //서류 등장 연출
            _docObj.transform.DOMove(new Vector3(_docStopPosX, _docObj.transform.position.y, _docObj.transform.position.z), _duration)
                .SetEase(Ease.Linear)
                .OnComplete(() =>
                {
                    _docObj.transform.DOMove(new Vector3(_docObj.transform.position.x, _docStopPosY, _docObj.transform.position.z), _duration)
                        .SetEase(Ease.Linear);
                });
    
            _isClickable = true;
        }
        
        // 도장 생성 함수
        public void ShowStamp(bool isApproved)
        {
            if (_docObj == null) return;
    
            GameObject prefab = isApproved ? approvalStampPrefab : deniedStampPrefab;
            GameObject currentStamp = Instantiate(prefab, _docObj.transform, false);
    
            currentStamp.transform.localPosition = new Vector2(1f, -2f);
            
            // TODO: 도장 찍히는 연출
        }
        
        
        //장애물이 치워지면 호출될 함수
        public void ObstacleCleared(GameObject obstacleObj)
        {
            if (_obstacleObjs.Contains(obstacleObj))
            {
                _obstacleObjs.Remove(obstacleObj);
            }
    
            if (_obstacleObjs.Count == 0)
            {
                GameManager.Instance.GetClassification().obstacle = false;
            }
        }
        
        // 서류 치우기 함수
        public void RemoveDocument()
        {
            _isClickable = false;
            
            //서류 퇴장 연출
            _docObj.transform.DOMove(new Vector3(_docObj.transform.position.x, _docDespawnY, _docObj.transform.position.z), _duration)
                .SetEase(Ease.Linear)
                .OnComplete(() =>
                {
                    _docObj.transform.DOMove(new Vector3(_docDespawnX, _docObj.transform.position.y, _docObj.transform.position.z), _duration)
                        .SetEase(Ease.Linear)
                        .OnComplete(() => ReloadDocument());
                });
        }
    
        public void ReloadDocument(bool noLoop = false)
        {
            if (_docObj != null)
            {
                // 자식 오브젝트들을 먼저 풀에 반환
                for (int i = _docObj.transform.childCount - 1; i >= 0; i--)
                {
                    var child = _docObj.transform.GetChild(i).gameObject;
                    if (child.CompareTag("Stamp"))
                    {
                        Destroy(child);
                    }
                    else
                    {
                        DocumentPool.Instance.ReturnObject(child);
                    }
                }
    
                // 마지막에 서류 자체 반환
                DocumentPool.Instance.ReturnObject(_docObj);
            }
    
            _currentObstacles.Clear();
            _obstacleObjs.Clear();
            _rejectObj = null;
            _docObj = null;
            GameManager.Instance.GetClassification().obstacle = false;
    
            if(!noLoop) CreateDocument();
        }
        void Update()
        {
            if (IsInputDown(out Vector2 inputPos))
            {
                RaycastHit2D hit = Physics2D.Raycast(inputPos, Vector2.zero);
    
                if (hit.collider != null)
                {
                    ObstacleController obstacle = hit.collider.GetComponent<ObstacleController>();
                    if (obstacle != null)
                    {
                        obstacle.ProcessHit();
                    }
                }
            }
        }
    
        private bool IsInputDown(out Vector2 inputPos)
        {
            inputPos = Vector2.zero;
    
    #if UNITY_EDITOR || UNITY_STANDALONE
            if (Input.GetMouseButtonDown(0))
            {
                inputPos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
                return true;
            }
    #elif UNITY_IOS || UNITY_ANDROID || UNITY_WEBGL
            if (Input.touchCount > 0 && Input.touches[0].phase == TouchPhase.Began)
            {
                inputPos = Camera.main.ScreenToWorldPoint(Input.touches[0].position);
                return true;
            }
    #endif
            return false;
        }
    
    }

     

    - InteractionUIController.cs

    <hide/>
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.UI;
    
    public class InteractionUIController : MonoBehaviour
    {
        [SerializeField] private RectTransform _rectTransform;
        [SerializeField] private CanvasGroup _canvasGroup;
        [SerializeField] private Button _acceptButton;
        [SerializeField] private Button _negativeButton;
        [SerializeField] private Button _pauseButton;
        
        private void Awake()
        {
            //버튼 클릭이벤트 등록
            _acceptButton.onClick.AddListener(OnClickAcceptButton);
            _negativeButton.onClick.AddListener(OnClickNegativeButton);
            _pauseButton.onClick.AddListener(OnClickPauseButton);
        }
        
        public void OnClickAcceptButton()
        {
            GameManager.Instance.GetClassification().confirm = true; //승인버튼 클릭시 서류 승인
            AudioManager.Instance.SFX.PlayStamp();
            GameManager.Instance.GetDocumentController().ShowStamp(true);
            GameManager.Instance.GetClassification().DocumentClassification(); // 서류 분류 메소드 호출
            
            //VFX 테스트 예시. 
            VfxManager.Instance.GetVFX(VFXType.TEST, new Vector2(0,0) , Quaternion.identity, Vector2.one);
        }
    
        public void OnClickNegativeButton()
        {
            GameManager.Instance.GetClassification().confirm = false; //반려버튼 클릭시 서류 반려
            AudioManager.Instance.SFX.PlayStamp();
            GameManager.Instance.GetDocumentController().ShowStamp(false);
            GameManager.Instance.GetClassification().DocumentClassification(); // 서류 분류 메소드 호출
        }
        
        public void OnClickPauseButton()
        {
            GameManager.Instance.PauseGame();
            UIManager.Instance.popupUIController.ShowPauseUI();
            AudioManager.Instance.SFX.PlayButtonClick();
        }
    }

     

    - Classification.cs

    <hide/>
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class Classification : MonoBehaviour
    {
        public bool obstacle; //장애물 유무 true: 장애물 있음, false: 장애물 없음
        public bool clean; //반려요소 true: 반려요소 없음, false: 반려요소 있음
        public bool confirm; //승인 여부 true: 승인버튼 클릭, false: 반려버튼 클릭
        public bool fever = false; //피버 여부 true: 피버 상태, false: 일반 상태
        //bool success; //분류 성공 여부 true: 성공, false: 실패
    
        int combo = 0; //콤보 횟수
        int maxCombo = 0; //최대 콤보 횟수
        float feverValue = 0; //피버 게이지
        float scoreMag = 1.0f; //점수 배율
        int score = 0; //점수
    
        public ScoreUIController scoreUIController; //점수 UI 컨트롤러
        public DocumentController docController;
        public void scoreMagnification()
        {
            switch (combo)
            {
                case int n when (n < 5):
                    scoreMag = 1.0f; //콤보 없음
                    break;
                case int n when (n >= 5 && n <= 10):
                    scoreMag = 1.1f; //콤보 1.1배
                    break;
                case int n when (n >= 11 && n <= 20):
                    scoreMag = 1.2f; //콤보 1.2배
                    break;
                case int n when (n >= 21 && n <= 30):
                    scoreMag = 1.3f; //콤보 1.3배
                    break;
                case int n when (n >= 31 && n <= 40):
                    scoreMag = 1.4f; //콤보 1.4배
                    break;
                case int n when (n >= 41 && n <= 50):
                    scoreMag = 1.5f; //콤보 1.5배
                    break;
                case int n when (n >= 51 && n <= 60):
                    scoreMag = 1.6f; //콤보 1.6배
                    break;
                case int n when (n >= 61 && n <= 70):
                    scoreMag = 1.7f; //콤보 1.7배
                    break;
                case int n when (n >= 71 && n <= 80):
                    scoreMag = 1.8f; //콤보 1.8배
                    break;
                case int n when (n >= 81 && n <= 90):
                    scoreMag = 1.9f; //콤보 1.9배
                    break;
                case int n when (n >= 91 && n <= 100):
                    scoreMag = 2.0f; //콤보 2배
                    break;
                case int n when (n >= 101):
                    scoreMag = 2.5f; //콤보 2.5배 
                    break;
            }
        } //점수 배율 조정
        public void DocumentClassification() //서류 분류 메소드
        {
            float time = GameManager.Instance.GetTimeController()._remainedTimerTime; // 남은 일과시간
            int day = GameManager.Instance.GetTimeController()._day; // 남은 진행일수
            // float time = TimeController.Instance._remainedTime; // 남은 일과시간
            // int day = TimeController.Instance._day; // 남은 진행일수
    
            if (obstacle) // 장애물이 있을 때 
            {
                //success = false;
                time -= 5 * day; //일과시간 감소
                combo = 0; //콤보 초기화
                feverValue -= (float)(feverValue * 0.1); //피버 게이지 감소
                scoreMagnification(); //점수 배율 적용
                UpdateScoreMagUI(); //점수 배율 UI 갱신
                UpdateComboUI();
                UpdateFeverUI(); //피버 게이지 UI 갱신
                Debug.Log("분류 실패! 장애물 있음. 일과시간 감소: " + time + ", 현재 콤보: " + combo + ", 최대 콤보: " + maxCombo + "점수 배율: " + scoreMag + "피버게이지: " + feverValue);
            }
            else // 장애물이 없을 때
            {
                if (clean) // 반려요소가 없을 때
                {
                    if (confirm) // 승인 버튼 클릭 시
                    {
                        //success = true;
                        time += 1 * day; //일과시간 증가
                        combo += 1; //콤보 증가
                        if (!fever)
                            feverValue += 3 * scoreMag;
                        score += (int)((1 * day) * scoreMag); //점수 증가
                        scoreMagnification(); //점수 배율 적용
                        UpdateScoreUI(); //점수 UI 갱신
                        UpdateScoreMagUI(); //점수 배율 UI 갱신
                        UpdateComboUI();
                        UpdateFeverUI(); //피버 게이지 UI 갱신
                        if (combo > maxCombo)
                        {
                            maxCombo = combo; //최대 콤보 갱신
                        }
                        if (feverValue >= 100) // 피버 게이지가 100 이상일 때
                        {
                            fever = true; // 피버 상태로 변경
                            feverValue = 0; // 피버 게이지 초기화
                            Debug.Log("피버 상태 진입! 현재 콤보: " + combo + ", 최대 콤보: " + maxCombo);
                        }
                        Debug.Log("분류 성공! 일과시간 증가: " + time + ", 현재 콤보: " + combo + ", 최대 콤보: " + maxCombo + "점수 배율: " + scoreMag + "피버게이지: " + feverValue);
                    }
                    else // 반려 버튼 클릭 시
                    {
                        //success = false;
                        time -= 5 * day; //일과시간 감소
                        combo = 0; //콤보 초기화
                        feverValue -= (float)(feverValue * 0.1); //피버 게이지 감소
                        scoreMagnification(); //점수 배율 적용
                        UpdateScoreMagUI(); //점수 배율 UI 갱신
                        UpdateComboUI();
                        UpdateFeverUI(); //피버 게이지 UI 갱신
                        Debug.Log("분류 실패! 일과시간 감소: " + time + ", 현재 콤보: " + combo + ", 최대 콤보: " + maxCombo + "점수 배율: " + scoreMag + "피버게이지: " + feverValue);
                    }
                }
                else // 반려요소가 있을 때
                {
                    if (confirm) // 승인 버튼 클릭 시
                    {
                        //success = false;
                        time -= 5 * day; //일과시간 감소
                        combo = 0; //콤보 초기화
                        feverValue -= (float)(feverValue * 0.1); //피버 게이지 감소
                        scoreMagnification(); //점수 배율 적용
                        UpdateScoreMagUI(); //점수 배율 UI 갱신
                        UpdateComboUI();
                        UpdateFeverUI(); //피버 게이지 UI 갱신
                        Debug.Log("분류 실패! 반려요소 있음. 일과시간 감소: " + time + ", 현재 콤보: " + combo + ", 최대 콤보: " + maxCombo + "점수 배율: " + scoreMag + "피버게이지: " + feverValue);
                    }
                    else // 반려 버튼 클릭 시
                    {
                        //success = true;
                        time += 1 * day; //일과시간 증가
                        combo += 1; //콤보 증가
                        if (!fever)
                            feverValue += 3 * scoreMag;
                        score += (int)((1 * day) * scoreMag); //점수 증가
                        scoreMagnification(); //점수 배율 적용
                        UpdateScoreUI(); //점수 UI 갱신
                        UpdateScoreMagUI(); //점수 배율 UI 갱신
                        UpdateComboUI();
                        UpdateFeverUI(); //피버 게이지 UI 갱신
                        if (combo > maxCombo)
                        {
                            maxCombo = combo; //최대 콤보 갱신
                        }
                        if (feverValue >= 100) // 피버 게이지가 100 이상일 때
                        {
                            fever = true; // 피버 상태로 변경
                            feverValue = 0; // 피버 게이지 초기화
                            Debug.Log("피버 상태 진입! 현재 콤보: " + combo + ", 최대 콤보: " + maxCombo);
                        }
                        Debug.Log("분류 성공! 반려요소 없음. 일과시간 증가: " + time + ", 현재 콤보: " + combo + ", 최대 콤보: " + maxCombo + "점수 배율: " + scoreMag + "피버게이지: " + feverValue);
                    }
                }
            }
    
            GameManager.Instance.GetTimeController().SetRemainedTimer(time); // 남은 일과시간 갱신
            GameManager.Instance.GetTimeController().SetDay(day); // 남은 진행일수 갱신
            docController.RemoveDocument(); // 서류 재생성
        }
    
        public void UpdateScoreUI() // 점수 UI 갱신 메소드
        {
            if (UIManager.Instance.inGameUIController.scoreUIController.score is var scoreText && scoreText != null)
                scoreText.text = GameManager.Instance.GetClassification().score.ToString("F0");
        }
    
        public void UpdateScoreMagUI() // 점수 배율 UI 갱신 메소드
        {
            if (UIManager.Instance.inGameUIController.scoreUIController.scoreMag is var scoreMagText && scoreMagText != null)
                scoreMagText.text = "x" + GameManager.Instance.GetClassification().scoreMag.ToString("F1");
        }
    
        public void UpdateComboUI() // 콤보 UI 갱신 메소드
        {
            if (UIManager.Instance.inGameUIController.comboUIController.comboText is var comboText && comboText != null)
                comboText.text = GameManager.Instance.GetClassification().combo.ToString();
        }
    
        public void UpdateFeverUI() // 피버 게이지 UI 갱신 메소드
        {
            var feverSlider = UIManager.Instance.inGameUIController.feverUIController.feverSlider;
            if (feverSlider != null)
            {
                feverSlider.value = feverValue / 100; // 현재 Classification의 feverValue로 갱신
            }
        }
        public void InitScore()
        {
            score = 0; // 점수 초기화
            combo = 0; // 콤보 초기화
            maxCombo = 0; // 최대 콤보 초기화
            feverValue = 0; // 피버 게이지 초기화
            scoreMag = 1.0f; // 점수 배율 초기화
            UpdateScoreUI(); // 점수 UI 갱신
            UpdateScoreMagUI(); // 점수 배율 UI 갱신
            UpdateComboUI(); // 콤보 UI 갱신
            UpdateFeverUI(); // 피버 게이지 UI 갱신
        }
    
        public int GetMaxCombo()
        {
            return maxCombo;
        }
    
        public int GetScore()
        {
            return score;
        }
    }

     

    게임 결과창 이펙트 구현

    : 다음 일감으로 게임 결과창의 이펙트를 구현하는 것을 가져왔다.

    • 게임 결과 표시 이펙트
    • New Record 시, 추가 이펙트

    --> 표시해야 할 게임 결과 : Day 수, MaxCombo 수, 점수

     

    ▶ 게임 결과를 표시할 때, 게임 결과가 순차적으로 나오고 그 값도 바로 결과값이 나오는 게 아니라 숫자가 0에서부터 점수가 올라서 결과값이 나오도록 구현해보자.

     

    >> Coroutine으로 제작

    : Coroutine을 통해 결과가 순차적으로 나오도록 하면서 Mathf.Lerp()를 사용하여 값이 점차 증가하도록 구현

     

    - ResultUIController.cs

    <hide/>
    using System.Collections;
    using System.Collections.Generic;
    using TMPro;
    using UnityEngine;
    using UnityEngine.UI;
    
    public class ResultUIController : PopupController
    {
        [SerializeField] private RectTransform _rectTransform;
        [SerializeField] private CanvasGroup _canvasGroup;
        [SerializeField] private Button _quitButton;
    
        [SerializeField] private TMP_Text dayText;
        [SerializeField] private TMP_Text maxComboText;
        [SerializeField] private TMP_Text scoreText;
    
        void Awake()
        {
            _quitButton.onClick.AddListener(OnClickQuitButton);
        }
        
        public void ShowPopup()
        {
            base.ShowPopup(gameObject);
        }
        
        public void ClosePopup()
        {
            base.ClosePopup(gameObject);
        }
        
        public void InitResultItem(GameResultData resultData)
        {        
            // 처음에는 0으로 초기화
            dayText.text = "0";
            maxComboText.text = "0";
            scoreText.text = "0";
            
            // 연출 Coroutine 시작
            StartCoroutine(PlayResultEffect(resultData));
        }
        
        // 결과창 연출 Coroutine
        private IEnumerator PlayResultEffect(GameResultData resultData)
        {
            // Day Count Up
            yield return StartCoroutine(CountUpText(dayText, resultData.Day, 1f));
            yield return new WaitForSeconds(0.2f);
            
            // MaxCombo Count Up
            yield return StartCoroutine(CountUpText(maxComboText, resultData.MaxCombo, 1f));
            yield return new WaitForSeconds(0.2f);
            
            // Score Count Up
            yield return StartCoroutine(CountUpText(scoreText, resultData.Score, 1.5f));
        }
        
        // 결과 Text를 CountUp 해주는 Coroutine
        private IEnumerator CountUpText(TMP_Text text, int count, float duration)
        {
            float timer = 0f;
            int startCount = 0;
    
            while (timer < duration)
            {
                timer += Time.deltaTime;
                float t = Mathf.Clamp01(timer / duration);
                int currentCount = Mathf.RoundToInt(Mathf.Lerp(startCount, count, t));
                text.text = currentCount.ToString();
                yield return null;
            }
            
            text.text = count.ToString();
        }
    
        public void OnClickQuitButton()
        {
            ClosePopup();
            GameManager.Instance.ResumeGame();
            GameManager.Instance.inGameController.QuitGame();
        }
    }

     

    >> DOTween으로 제작

    : 현재 우리 프로젝트는 DOTween의 무료버전을 사용하고 있다. DOTween을 사용하면 더욱 간단하면서도 최적화된 성능으로 구현할 수 있기 때문에 DOTween으로 다시 제작해보았다.

     

    ※ DOTween의 기능을 잘 설명해놓은 블로그

     

    [Asset] Unity3D 'DOTween' 1 : 기본 기능과 팁

    +23.07.26 내용 수정 및 보완 [ DOTween ] 오브젝트의 애니메이션 혹은 부드러운 값 변경 시 기존의 유...

    blog.naver.com

     

     

    [Asset] Unity3D 'DOTween' 2 : Sequence와 팁

    ※ 1편 [ Sequence ] 하나의 변환을 가진 Tween들을 시간과 순서에 맞춰 배열하여 연속된 하나의 장면을 ...

    blog.naver.com

     

    ※ Coroutine vs DOTween

    : DOTween이 Coroutine보다 퍼포먼스적으로 최적화된 이유

    1. GC(Garbage Collection) 발생이 적음
      • Coroutine의 문제점
        •     IEnumerator를 반환하는 Coroutine은 실행될 때마다 새로운 개체를 생성
        •     반복문을 통해 매 프레임마다 yield return을 실행하면서 GC 발생 가능성 증가
        •     특히 WaitForSecond와 같은 객체는 매번 새로 생성되므로 메모리 할당이 지속적으로 발생
      • DOTween의 최적화 방식
        •     DOTween은 내부적으로 메모리를 미리 할당하고 재사용(Pooling)하는 방식을 사용
        •     애니메이션을 실행할 때마다 새로운 객체를 생성하는 것이 아니라, 기존 트윈(Tween)을 재사용하여 메모리 낭비를 최소화
    2. 더 높은 성능의 타이밍 제어 (FixedUpdate vs Time.deltaTime)
      • Coroutine의 문제점
        •     Coroutine은 Time.deltaTime을 기반으로 실행되며, 프레임이 불규칙할 경우 애니메이션이 끊길 가능성이 있음.
        •     WaitForSecond는 정확한 타이밍 제어가 어려움
        •     FixedUpdate에서 실행하면 프레임에 따라 타이밍이 일정하지 않을 수 있음
      • DOTween의 최적화 방식
        •     고유한 타이머 시스템을 사용하여 Time.timeScale과 관계없이 부드러운 애니메이션 적용
        •     프레임 속도에 영향을 받지 않고 일정한 속도로 트윈을 진행할 수 있음
    3. 간결한 코드로 인해 오버헤드 감소
      • Coroutine의 문제점
        •     애니메이션을 구현할 때 Coroutine은 여러 줄의 코드가 필요하고 유지보수가 어려움
        •     여러 애니메이션을 동시에 실행하려면 여러 개의 Coroutine을 관리해야 함
        •     복잡한 Chaining 애니메이션을 만들 경우 각각의 타이밍을 수동으로 조절해야 하는 번거로움
      • DOTween의 최적화 방식
        •     한 줄의 코드로도 복잡한 애니메이션을 구현 가능
        •     SetEase(), OnComplete(), SetLoops() 등을 활용하여 더 직관적이고 효율적인 코드 작성 가능
        •     Chaining 기능을 지원하여 여러 애니메이션을 간단하게 연결 가능
     

    [Unity] DOTween

    DOTween은 Unity에서 트윈(Tween) 애니메이션을 쉽게 적용할 수 있도록 도와주는 강력한 애니메이션 라이브러리로 기존의 Lerp, Coroutine을 사용하는 방식보다 더 직관적이고 성능 최적화된 애니메이션

    habbn-unitystudy.tistory.com

     

    >> 작성한 코드

    - ResultUIController.cs

    : New Record일 때도 임시 코드 추가

    <hide/>
    using System.Collections;
    using System.Collections.Generic;
    using TMPro;
    using UnityEngine;
    using UnityEngine.UI;
    using DG.Tweening;
    
    public class ResultUIController : PopupController
    {
        [SerializeField] private RectTransform _rectTransform;
        [SerializeField] private CanvasGroup _canvasGroup;
        [SerializeField] private Button _quitButton;
    
        [SerializeField] private TMP_Text dayText;
        [SerializeField] private TMP_Text maxComboText;
        [SerializeField] private TMP_Text scoreText;
    
        [Header("New Record UI")]
        [SerializeField] private TMP_Text newRecordText;
    
        void Awake()
        {
            _quitButton.onClick.AddListener(OnClickQuitButton);
        }
        
        public void ShowPopup()
        {
            base.ShowPopup(gameObject);
        }
        
        public void ClosePopup()
        {
            base.ClosePopup(gameObject);
        }
        
        public void InitResultItem(GameResultData resultData)
        {
            // 처음에는 0으로 초기화
            dayText.text = "0";
            maxComboText.text = "0";
            scoreText.text = "0";
            
            // TODO: 유저의 최고기록 불러오기 (임시: PlayerPrefs)
            int bestScore = PlayerPrefs.GetInt("BestScore", 0);
            
            Sequence seq = DOTween.Sequence();
            
            // Day Count Up
            seq.Append(DOTween.To(() => 0, x => dayText.text = x.ToString(), resultData.Day, 1f));
            seq.AppendInterval(0.2f);
            
            // MaxCombo Count Up
            seq.Append(DOTween.To(() => 0, x => maxComboText.text = x.ToString(), resultData.MaxCombo, 1f));
            seq.AppendInterval(0.2f);
    
            // Score Count Up
            seq.Append(DOTween.To(() => 0, x => scoreText.text = x.ToString(), resultData.Score, 1.5f));
    
            // TODO: 최종 이벤트 추가
            seq.OnComplete(() =>
            {
                Debug.Log("결과 연출 완료!");
                
                // New Record 체크
                if (resultData.Score > bestScore)
                {
                    PlayerPrefs.SetInt("BestScore", resultData.Score);
                    ShowNewRecordEffect();
                }
            });
        }
    
        private void ShowNewRecordEffect()
        {
            if (newRecordText != null)
            {
                newRecordText.gameObject.SetActive(true);
                newRecordText.alpha = 0f;
                newRecordText.DOFade(1f, 1f).SetLoops(2, LoopType.Yoyo);    // 임시 효과
            }
        }
    
        public void OnClickQuitButton()
        {
            ClosePopup();
            GameManager.Instance.ResumeGame();
            GameManager.Instance.inGameController.QuitGame();
        }
    }

     

    ▶ 현재 일과시간이 0초가 돼도 게임이 끝나지 않는 버그가 있어서 테스트가 불가능했다.

    --> 그래서 모든 효과를 임시로 짰고, 제대로 작동되는지 모른다.