본문 바로가기
Development/Internship

[멋사 로켓단 인턴쉽] 추가 작업 - Stamp Effect

by Mobics 2025. 8. 18.

목차


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

    25.08.17

    도장 생성 함수

    : 도장 Sprite를 가진 GameObject를 프리팹으로 받아서 승인 버튼을 눌렀을 때는 승인 도장 Sprite를, 반려 버튼을 눌렀을 때는 반려 도장 Sprite를 서류 위에 띄우도록 구현

    --> 이때, 도장 Sprite는 서류 Object의 자식으로 붙어서 서류가 처리되어 사라질 때 서류와 같이 붙어서 이동하고 사라지도록

     

    >> 도장 프리팹 생성

    : 빈 게임 오브젝트에 SpriteRenderer만 추가하여 Sprite를 바인딩한 후, Prefab화

     

    >> 코드 작성

    : 처음엔 InteractionUIController.cs에서 도장 프리팹을 받아서 누른 버튼에 따라 해당하는 도장이 찍히도록 했음.

     

    - DocumentController.cs

    <hide/>
    // 도장 생성 함수
    public void ShowStamp(GameObject stampPrefab)
    {
        if (stampPrefab == null || _docObj == null) return;
    
        // 서류 Object를 도장 Object의 부모로 설정하여 생성 
        GameObject stampObj = Instantiate(stampPrefab, _docObj.transform);
        
        // 도장 위치 설정
        Vector2 stampPos = new Vector2(1f, -2f);    // 도장 위치 고정, 추후 수정 가능
        stampObj.transform.localPosition = stampPos;
        
        // TODO: 도장 찍히는 연출
    }

     

    - 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;
        
        [Header("도장 관련")]
        [SerializeField] private GameObject approvalStampPrefab;
        [SerializeField] private GameObject deniedStampPrefab;
        
        private void Awake()
        {
            //버튼 클릭이벤트 등록
            _acceptButton.onClick.AddListener(OnClickAcceptButton);
            _negativeButton.onClick.AddListener(OnClickNegativeButton);
            _pauseButton.onClick.AddListener(OnClickPauseButton);
        }
        
        public void OnClickAcceptButton()
        {
            Classification.Instance.confirm = true; //승인버튼 클릭시 서류 승인
            Classification.Instance.DocumentClassification(); // 서류 분류 메소드 호출
            AudioManager.Instance.SFX.PlayStamp();
            GameManager.Instance.GetDocumentController().ShowStamp(approvalStampPrefab);
        }
    
        public void OnClickNegativeButton()
        {
            Classification.Instance.confirm = false; //반려버튼 클릭시 서류 반려
            Classification.Instance.DocumentClassification(); // 서류 분류 메소드 호출
            AudioManager.Instance.SFX.PlayStamp();
            GameManager.Instance.GetDocumentController().ShowStamp(deniedStampPrefab);
        }
        
        public void OnClickPauseButton()
        {
            GameManager.Instance.PauseGame();
            UIManager.Instance.popupUIController.ShowPauseUI();
            AudioManager.Instance.SFX.PlayButtonClick();
        }
    }

     

    >> 코드 수정

    : 하지만 역할을 깔끔하게 정리하려면 DocumentController에서 도장 프리팹을 받고, 도장을 선택하는 것까지 관리하는 것이 나을 것 같다는 생각에 DocumentController에서 처리하도록 옮김

     

    - DocumentController.cs

    <hide/>
    [Header("도장 관련")]
    [SerializeField] private GameObject approvalStampPrefab;
    [SerializeField] private GameObject deniedStampPrefab;
    
    public void ShowStamp(bool isApproved)
    {
        if (_docObj == null) return;
    
        GameObject prefab = isApproved ? approvalStampPrefab : deniedStampPrefab;
        GameObject currentStamp = Instantiate(prefab, _docObj.transform);
    
        Vector2 stampPos = new Vector2(1f, -2f);
        currentStamp.transform.localPosition = stampPos;
        
        // TODO: 도장 찍히는 연출
    }

     

    - 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()
        {
            Classification.Instance.confirm = true; //승인버튼 클릭시 서류 승인
            Classification.Instance.DocumentClassification(); // 서류 분류 메소드 호출
            AudioManager.Instance.SFX.PlayStamp();
            GameManager.Instance.GetDocumentController().ShowStamp(true);
        }
    
        public void OnClickNegativeButton()
        {
            Classification.Instance.confirm = false; //반려버튼 클릭시 서류 반려
            Classification.Instance.DocumentClassification(); // 서류 분류 메소드 호출
            AudioManager.Instance.SFX.PlayStamp();
            GameManager.Instance.GetDocumentController().ShowStamp(false);
        }
        
        public void OnClickPauseButton()
        {
            GameManager.Instance.PauseGame();
            UIManager.Instance.popupUIController.ShowPauseUI();
            AudioManager.Instance.SFX.PlayButtonClick();
        }
    }

     

    >> 도장 프리팹 바인딩

     

    문제 발생 및 해결 과정

    : 도장 Sprite가 서류보다 아래에 생성되는 문제

    --> 여러 번 테스트 했을 때, 어떤 경우는 맨 위, 어떤 경우는 맨 뒤에 나오는 버그가 발생

    (좌) 맨 뒤에 나오는 경우 / (우) 맨 위에 나오는 경우

     

    >> 해결 과정

    1. SetAsLastSibling()을 사용

    : Unity에서 Hierarchy에 나열된 순서대로 UI 요소가 렌더링되기 때문에, Hierarchy상 가장 아래에 있는 요소가 마지막에 렌더링되어 UI상 가장 위에 위치하게 된다.

    --> 따라서 SetAsLastSibling()를 사용하여 도장 Prefab을 Hierarchy의 가장 아래로 내려서 가장 위에 위치하도록 한다.

     

    ※ Unity Documentation - Transform.SetAsLastSibling()

    https://docs.unity3d.com/6000.2/Documentation/ScriptReference/Transform.SetAsLastSibling.html

     

    - 작성한 코드

    <hide/>
    public void ShowStamp(bool isApproved)
    {
        if (_docObj == null) return;
    
        GameObject prefab = isApproved ? approvalStampPrefab : deniedStampPrefab;
        GameObject currentStamp = Instantiate(prefab, _docObj.transform);
    
        Vector2 stampPos = new Vector2(1f, -2f);
        currentStamp.transform.localPosition = stampPos;
        currentStamp.transform.SetAsLastSibling();
        
        // TODO: 도장 찍히는 연출
    }

     

    ▶ 그래도 여전히 같은 문제가 발생했다.

     

    2. 월드 좌표를 유지하지 않도록 설정

    : SetParent()에서 월드 좌표를 유지(true)하면 도장 프리팹을 Instantiate()하는 과정에서 월드 좌표/로컬 좌표 처리 방식이 Unity 내부 상황에 따라 달라져서 Stamp가 최상단이 되지 않는 경우가 발생할 수 있다.

    --> 도장 프리팹을 생성할 때 worldPositionStays를 false로 설정

     

    ※ Unity Documentation - Transform.SetParent()

    https://docs.unity3d.com/ScriptReference/Transform.SetParent.html

     

    - 작성한 코드

    <hide/>
    public void ShowStamp(bool isApproved)
    {
        if (_docObj == null) return;
    
        GameObject prefab = isApproved ? approvalStampPrefab : deniedStampPrefab;
        GameObject currentStamp = Instantiate(prefab, _docObj.transform, false);
    
        Vector2 stampPos = new Vector2(1f, -2f);
        currentStamp.transform.localPosition = stampPos;
        currentStamp.transform.SetAsLastSibling();
        
        // TODO: 도장 찍히는 연출
    }

     

    ▶ 수정해도 동일한 문제 발생, 심지어 어떤 경우에는 승인 도장은 서류 위에, 반려 도장은 서류 아래에 나오기도 한다.

     

    3. Order in Layer 설정

    : Document와 Stamp 모두 Image가 아니라 SpriteRenderer 기반이기 때문에 Image처럼 Hierarchy 순서에 따라 그려지는 것이 아니라 Sorting LayerOrder in Layer 값에 의해 결정된다.

    --> 결국 SetAsLastSibling()은 SpriteRenderer에 전혀 영향을 주지 않으며, Stamp 프리팹의 Order in Layer를 10으로 설정 (반려 요소의 Order in Layer는 5, 장애물의 Order in Layer는 6이기 때문에 그보다 우선순위를 높임)

     

    - 작성한 코드

    <hide/>
    // 도장 생성 함수
    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: 도장 찍히는 연출
    }

     

    ▶ 드디어 문제가 해결됐다!

     

    최종 코드

    1. DocumentController.cs

    <hide/>
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.Serialization;
    using DG.Tweening;
    using UnityEngine.UI;
    
    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;
        
        public void InitDocuments()
        {
            Classification.Instance.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);
            Classification.Instance.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)
            {
                Classification.Instance.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);
                });
            
            //todo: 버튼 클릭 활성화
        }
        
        // 도장 생성 함수
        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)
            {
                Classification.Instance.obstacle = false;
            }
        }
        
        // 서류 치우기 함수
        public void RemoveDocument()
        {
            //todo: 버튼 연타 방지
            
            //서류 퇴장 연출
            _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()
        {
            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;
            Classification.Instance.obstacle = false;
    
            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;
        }
    
    }

     

    2. 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()
        {
            Classification.Instance.confirm = true; //승인버튼 클릭시 서류 승인
            Classification.Instance.DocumentClassification(); // 서류 분류 메소드 호출
            AudioManager.Instance.SFX.PlayStamp();
            GameManager.Instance.GetDocumentController().ShowStamp(true);
        }
    
        public void OnClickNegativeButton()
        {
            Classification.Instance.confirm = false; //반려버튼 클릭시 서류 반려
            Classification.Instance.DocumentClassification(); // 서류 분류 메소드 호출
            AudioManager.Instance.SFX.PlayStamp();
            GameManager.Instance.GetDocumentController().ShowStamp(false);
        }
        
        public void OnClickPauseButton()
        {
            GameManager.Instance.PauseGame();
            UIManager.Instance.popupUIController.ShowPauseUI();
            AudioManager.Instance.SFX.PlayButtonClick();
        }
    }