개발일지 (9)
2025-04-03 23:24:32

 

 

단순히 플레이어를 쫓아오기만 하는 적만 있으면 게임이 많이 심심할 것이다.

여러가지 변수를 주기 위해 행동 패턴이 있는 적들을 구현하였다.

 

위 두가지 종류의 적 모두의 핵심은 기존에 구현해놓았던 상태머신을 활용하여
각 캐릭터마다 고유의 발동 조건을 추가하여 상태가 변경되게끔 하였다.

 

플레이어와의 거리를 측정할 때는 성능을 위해 제곱 거리를 사용했다.

 

 

원거리 적같은 경우 투사체 발사 연출 때문에 조금 까다로웠는데,

준비 시간동안 생성한 투사체를 머리위에 올리고, 준비시간이 지나면 생성한 투사체를 플레이어에게

연달아 3개를 발사하는 방식이었다.

 

투사체를 머리 위에 상승 시키는 방법은 갓 DOTween을 활용하였다.

 

프로젝트를 진행하면서 깨달은 중요한 점 : 상태 초기화, 코루틴 정리, 플래그 초기화, 풀 반환등의 정리인듯

 protected override void OnDisable()
 {
     base.OnDisable();

     // DOTween 시퀀스 정리
     if (colorChangeSequence != null)
     {
         colorChangeSequence.Kill();
         colorChangeSequence = null;
     }

     // 상태 초기화
     if (spriteRenderer != null)
     {
         spriteRenderer.color = originalColor;
     }

     // 넉백 면역 상태 초기화 (캐싱된 컴포넌트 사용)
     if (isImmuneToKnockbackWhilePreparing && enemyComponent != null)
     {
         enemyComponent.SetKnockbackImmunity(false);
     }

     // 활성 투사체 모두 풀로 반환
     ClearAllProjectiles();

     // 상태 플래그 초기화
     isPreparingProjectile = false;
     isFiring = false;
 }
2025-03-31 22:06:13

 

우선 UI 요소는 기본적으로 물리 시스템과 직접 상호작용하지 않기 때문에 물리법칙을 적용하는 것은 큰 과제였다.
Rigidbody2D를 사용할 수 없기 때문.

따라서 UI 환경에 맞게 설계된 물리 시스템을 구현해야 했다.

private void UpdatePhysics(float deltaTime)
{
    // 중력 적용
    velocity += Vector2.down * gravityScale * deltaTime;
    
    // 위치 업데이트
    rectTransform.position += (Vector3)velocity * deltaTime;
    
    // 경계 충돌 확인 및 처리
    CheckBoundaryCollisions();
    
    // 공기 저항 (감속) 적용
    velocity *= dragDamping;
}

 

 

무기(아이템) 이미지는 터치 밑 홀드를 통하여 들어올릴 수 있고, 인벤토리 그리드가 아닌 곳에 아이템을 놓으면 물리법칙이 적용된 객체로 변경되어 상호작용되어야 한다.

public void ConvertToPhysicsItem(InventoryItem item, Vector2 position)
{
    // 1. 그리드에서 아이템 제거
    if (mainGrid != null && item.OnGrid)
    {
        mainGrid.RemoveItem(item.GridPosition);
    }
    
    // 2. 캔버스의 자식으로 설정
    item.transform.SetParent(parentCanvas.transform, false);
    item.transform.localScale = new Vector3(6, 6, 1);
    
    // 3. UI 위치 설정
    RectTransform rt = item.GetComponent<RectTransform>();
    rt.position = new Vector3(position.x, position.y, rt.position.z);
    
    // 4. 그리드 위치 초기화
    item.SetGridPosition(new Vector2Int(-1, -1));
    
    // 5. 물리 컴포넌트 추가/획득 및 활성화
    PhysicsInventoryItem physicsItem = item.GetComponent<PhysicsInventoryItem>();
    if (physicsItem == null)
    {
        physicsItem = item.gameObject.AddComponent<PhysicsInventoryItem>();
        physicsItem.ForceInitialize();
    }
    
    // 6. 초기 속도와 함께 물리 활성화
    Vector2 initialVelocity = Vector2.up * 100f + new Vector2(Random.Range(-50f, 50f), 0f);
    physicsItem.SetSpawnPosition(rt.position);
    physicsItem.ActivatePhysics(initialVelocity, position);
}

 

 

UI 캔버스는 별도의 좌표계를 사용하므로, 화면 경계와의 충돌을 특별히 처리해야 했다.

private void CheckBoundaryCollisions()
{
    // 캔버스 좌표계에서 화면 경계 계산
    Vector3[] corners = new Vector3[4];
    canvasRectTransform.GetWorldCorners(corners);
    
    // 경계 값 (좌측, 우측, 하단, 상단)
    float minX = corners[0].x;
    float maxX = corners[2].x;
    float minY = corners[0].y;
    float maxY = corners[2].y;
    
    // 설정된 바닥 위치 사용
    if (FloorY > 0) minY = FloorY;
    
    // 충돌 및 바운스 로직
    Vector2 halfSize = rectTransform.sizeDelta * rectTransform.localScale / 2f;
    Vector3 pos = rectTransform.position;
    
    // X축 충돌 처리
    if (pos.x + halfSize.x > maxX)
    {
        pos.x = maxX - halfSize.x;
        velocity.x = -velocity.x * bounceMultiplier;
        // 추가 감속 로직...
    }
    // Y축 충돌 처리...
}

 

물리법칙이 적용된 아이템과 상호작용을 하기 위해서 이벤트 시스템을 활용,
레이캐스트를 사용하기로 했다.

private PhysicsInventoryItem GetPhysicsItemAtPosition(Vector2 screenPosition)
{
    // EventSystem의 레이캐스트를 사용하여 UI 요소 찾기
    PointerEventData eventData = new PointerEventData(EventSystem.current);
    eventData.position = screenPosition;
    List<RaycastResult> results = new List<RaycastResult>();
    EventSystem.current.RaycastAll(eventData, results);
    
    // 결과 처리
    foreach (RaycastResult result in results)
    {
        // 컴포넌트 체크 및 반환
        PhysicsInventoryItem physicsItem = result.gameObject.GetComponent<PhysicsInventoryItem>();
        if (physicsItem != null)
        {
            return physicsItem;
        }
        
        // 부모 객체 체크
        Transform parentTransform = result.gameObject.transform.parent;
        if (parentTransform != null)
        {
            physicsItem = parentTransform.GetComponent<PhysicsInventoryItem>();
            if (physicsItem != null)
            {
                return physicsItem;
            }
        }
        
        // InventoryItem 체크
        InventoryItem inventoryItem = result.gameObject.GetComponent<InventoryItem>();
        if (inventoryItem != null)
        {
            physicsItem = inventoryItem.GetComponent<PhysicsInventoryItem>();
            if (physicsItem != null && physicsItem.IsPhysicsActive)
            {
                return physicsItem;
            }
        }
    }
    
    return null;
}

 

 

모바일 게임이기도 하고, 물리 계산은 리소스를 많이 사용하므로 최적화도 신경썼다.

// 'Sleep' 상태를 통한 최적화
if (!isSleeping && velocity.sqrMagnitude < minimumVelocity * 0.5f)
{
    if (Time.time - lastMoveTime > 1.0f)
    {
        isSleeping = true;
    }
}

// Sleep 상태면 물리 계산 최소화
if (isSleeping)
{
    // 간헐적 충돌 검사만 수행
    if (Time.frameCount % 30 == 0)
    {
        CheckBoundaryCollisions();
    }
    return;
}

//아이템 개수에 따른 업데이트 빈도 수 조절
private int DetermineUpdateInterval()
{
    int itemCount = physicsItems.Count;
    
    if (itemCount < 10) return 10;      // 10개 미만: 매 10프레임마다
    else if (itemCount < 20) return 20; // 10-20개: 매 20프레임마다
    else if (itemCount < 30) return 30; // 20-30개: 매 30프레임마다
    else return 60;                     // 30개 이상: 매 60프레임마다
}

 

 

UI의 버튼 부분을 바닥으로 설정하고 싶어, 하단 UI 요소를 기준으로 바닥 위치를 동적으로 계산하였다.

private void CacheFloorPosition()
{
    float buttonTopY = 0f;
    
    // 버튼 위치로부터 바닥 높이 계산
    if (shopButtonRect != null)
    {
        Vector3[] corners = new Vector3[4];
        shopButtonRect.GetWorldCorners(corners);
        buttonTopY = Mathf.Max(buttonTopY, corners[1].y);
    }
    
    if (progressButtonRect != null)
    {
        Vector3[] corners = new Vector3[4];
        progressButtonRect.GetWorldCorners(corners);
        buttonTopY = Mathf.Max(buttonTopY, corners[1].y);
    }
    
    // 바닥 위치 설정 (버튼 위에 약간의 여백)
    cachedFloorY = buttonTopY + floorOffsetFromButtons;
}

 

 

이번 구현을 통해 최적화와 사용자 경험 두 측면을 모두 고려하여 설계하는 방법을 조금은 터득한 것 같았다.
조작감은 최대한 직관적이게 하고, 오브젝트 풀링, 적응형 업데이트 간격, 'Sleep' 상태 등의 기법을 통해
모바일 환경에서 원할하게 작동할 수 있도록 노력하였다.

2025-03-31 21:45:20

게임을 실행했을 때 간단한 스토리텔링을 위하여 인트로 화면 작업을 진행하였다.

캔버스 구조는 이렇게 구성하였다.
검은 화면 밑 텍스트는 설정한 타이밍에 따라 페이드인/아웃과 타이핑하는 듯한 텍스트 효과를 주었다.
그리고 아래로 긴 스크롤 이미지 연출을 위해 DOTWeen과 코루틴을 활용하였다.

using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Collections;
using System.Collections.Generic;
using DG.Tweening;
using UnityEngine.SceneManagement;

/// <summary>
/// 인트로 시퀀스 실행 및 관리를 담당하는 클래스
/// 최적화된 버전으로 GameManager와 통합됨
/// </summary>
public class IntroSequenceManager : MonoBehaviour
{
    [Header("UI References")]
    [SerializeField] private Image blackOverlay;           // 페이드 인/아웃용 검은색 이미지
    [SerializeField] private RectTransform scrollImage;    // 스크롤될 세로 이미지
    [SerializeField] private TextMeshProUGUI introText;    // 텍스트 표시용 UI

    [Header("Panel Fade Settings")]
    [SerializeField] private float stepDuration = 0.3f;    // 각 단계 사이의 시간 간격
    [SerializeField] private int fadeSteps = 4;            // 알파값 단계 수
    [SerializeField] private float initialPanelAlpha = 1.0f;  // 시작 시 패널 알파값
    [SerializeField] private float finalPanelAlpha = 0.0f;    // 메인 화면에서의 패널 알파값

    [Header("Scroll Settings")]
    [SerializeField] private float scrollSpeed = 50f;      // 초당 스크롤 픽셀
    [SerializeField] private float initialDelay = 0.5f;    // 페이드 인 후 스크롤 시작 전 대기 시간
    [SerializeField] private float scrollEndY = 2000f;     // 스크롤이 끝나는 Y 위치
    [SerializeField] private float intervalBetweenTexts = 0.5f;  // 텍스트 사이 간격

    [System.Serializable]
    public class IntroTextItem
    {
        public string text;
        public float displayTime = 3.0f;  // 텍스트가 화면에 표시되는 시간
        public bool useTypewriterEffect = true;
        public float typingSpeed = 0.05f;  // 타이핑 속도 (글자당 초)

        [Header("Panel Settings")]
        public bool showPanelWithText = true;  // 텍스트 표시 시 패널 표시 여부
        public float panelAlpha = 0.5f;        // 텍스트 표시 시 패널 알파값 (0-1)
    }

    [SerializeField] private List<IntroTextItem> introTextSequence = new List<IntroTextItem>();

    // 이미지 위치 관련 변수
    private float scrollY = 0f;
    private bool isScrolling = false;
    private bool sequenceCompleted = false;
    private bool isTransitioning = false;

    // 코루틴 참조 관리
    private Coroutine introSequenceCoroutine;

    // 캐시된 WaitForSeconds 객체
    private WaitForSeconds initialDelayWait;
    private WaitForSeconds intervalWait;
    private WaitForSeconds stepDelayWait;
    private Dictionary<float, WaitForSeconds> typingDelays = new Dictionary<float, WaitForSeconds>();
    private Dictionary<float, WaitForSeconds> displayTimeWaits = new Dictionary<float, WaitForSeconds>();

    private void Awake()
    {
        // 성능 최적화를 위해 자주 사용되는 WaitForSeconds 객체 캐싱
        initialDelayWait = new WaitForSeconds(initialDelay);
        intervalWait = new WaitForSeconds(intervalBetweenTexts);
        stepDelayWait = new WaitForSeconds(stepDuration);

        // 타입라이터 효과와 표시 시간에 대한 WaitForSeconds 캐싱
        CacheWaitForSecondsObjects();
    }

    private void CacheWaitForSecondsObjects()
    {
        // 텍스트 표시 시간 캐싱
        HashSet<float> displayTimes = new HashSet<float>();
        HashSet<float> typingSpeeds = new HashSet<float>();

        foreach (var item in introTextSequence)
        {
            displayTimes.Add(item.displayTime);
            if (item.useTypewriterEffect)
            {
                typingSpeeds.Add(item.typingSpeed);
            }
        }

        // 고유한 표시 시간에 대한 WaitForSeconds 객체 생성
        foreach (float time in displayTimes)
        {
            if (!displayTimeWaits.ContainsKey(time))
            {
                displayTimeWaits[time] = new WaitForSeconds(time);
            }
        }

        // 고유한 타이핑 속도에 대한 WaitForSeconds 객체 생성
        foreach (float speed in typingSpeeds)
        {
            if (!typingDelays.ContainsKey(speed))
            {
                typingDelays[speed] = new WaitForSeconds(speed);
            }
        }
    }

    private void Start()
    {
        PrepareUI();

        SetupSounds();


        // 인트로 시퀀스 시작
        introSequenceCoroutine = StartCoroutine(PlayIntroSequence());
    }
    private void SetupSounds()
    {
        if (SoundManager.Instance != null)
        {
            // 현재 재생 중인 BGM 확인
            bool isBgmPlaying = SoundManager.Instance.IsBGMPlaying("BGM_Intro");

            // 인트로 사운드뱅크 로드 (아직 로드되지 않았다면)
            if (SoundManager.Instance.currentSoundBank == null ||
                SoundManager.Instance.currentSoundBank.name != "IntroSoundBank")
            {
                SoundManager.Instance.LoadSoundBank("IntroSoundBank");
            }

            // 이미 재생 중인 경우가 아니라면 BGM 재생
            if (!isBgmPlaying)
            {
                SoundManager.Instance.PlaySound("BGM_Intro", 1f, true);
            }
        }
        else
        {
            Debug.LogWarning("SoundManager not found!");
        }
    }
    private void PrepareUI()
    {
        // 초기 설정
        if (introText != null)
            introText.alpha = 0f;

        // 초기 패널 설정
        if (blackOverlay != null)
            blackOverlay.color = new Color(0, 0, 0, initialPanelAlpha);

        // 초기 스크롤 위치 설정
        scrollY = 0f;
        if (scrollImage != null)
            scrollImage.anchoredPosition = new Vector2(scrollImage.anchoredPosition.x, scrollY);
    }

    private IEnumerator PlayIntroSequence()
    {
        // 1. 시작 시 패널이 단계적으로 사라짐
        yield return StepFadePanel(initialPanelAlpha, finalPanelAlpha, fadeSteps, stepDuration);

        // 2. 초기 딜레이
        yield return initialDelayWait;

        // 3. 스크롤 시작
        isScrolling = true;

        // 4. 텍스트 시퀀스 시작
        yield return ShowTextSequence();

        // 5. 스크롤 종료를 기다림 (Update 함수에서 처리)
        while (!sequenceCompleted)
        {
            yield return null;
        }

        // 6. 종료 시 패널이 단계적으로 나타남
        yield return StepFadePanel(finalPanelAlpha, initialPanelAlpha, fadeSteps, stepDuration);

        // 7. 인트로 완료 후 타이틀씬으로 이동
        CompleteIntro();
    }

    private IEnumerator StepFadePanel(float startAlpha, float targetAlpha, int steps, float stepDelay)
    {
        if (blackOverlay == null) yield break;

        // 시작값과 목표값 사이의 간격 계산
        float alphaStep = (targetAlpha - startAlpha) / steps;
        Color color = blackOverlay.color;

        for (int i = 0; i <= steps; i++)
        {
            // 현재 단계에 맞는 알파값 계산
            color.a = startAlpha + (alphaStep * i);
            blackOverlay.color = color;

            // 다음 단계 전 대기
            yield return stepDelayWait;
        }
    }

    private IEnumerator ShowTextSequence()
    {
        if (introText == null) yield break;

        Color textColor = introText.color;
        Color panelColor = blackOverlay != null ? blackOverlay.color : Color.black;

        for (int i = 0; i < introTextSequence.Count; i++)
        {
            IntroTextItem textItem = introTextSequence[i];

            // 패널 표시 (텍스트와 함께)
            if (textItem.showPanelWithText && blackOverlay != null)
            {
                panelColor.a = textItem.panelAlpha;
                blackOverlay.color = panelColor;
            }

            if (textItem.useTypewriterEffect)
            {
                // 텍스트 초기화
                introText.text = "";
                textColor.a = 1f;
                introText.color = textColor;

                // 타이핑 효과
                yield return TypeText(textItem.text, textItem.typingSpeed);

                // 표시 시간 대기 (캐시된 WaitForSeconds 사용)
                yield return GetDisplayTimeWait(textItem.displayTime);
            }
            else
            {
                // 텍스트 설정
                introText.text = textItem.text;
                textColor.a = 1f;
                introText.color = textColor;

                // 표시 시간 대기 (캐시된 WaitForSeconds 사용)
                yield return GetDisplayTimeWait(textItem.displayTime);
            }

            // 텍스트 숨김
            textColor.a = 0f;
            introText.color = textColor;

            // 패널 알파값 되돌리기
            if (textItem.showPanelWithText && blackOverlay != null)
            {
                panelColor.a = finalPanelAlpha;
                blackOverlay.color = panelColor;
            }

            // 모든 텍스트에 동일한 간격 적용
            yield return intervalWait;
        }
    }

    // 텍스트가 타이핑되는 것처럼 한 글자씩 출력하는 함수
    private IEnumerator TypeText(string fullText, float typingSpeed)
    {
        WaitForSeconds typeDelay = GetTypingSpeedWait(typingSpeed);

        introText.text = "";
        for (int i = 0; i <= fullText.Length; i++)
        {
            introText.text = fullText.Substring(0, i);
            yield return typeDelay;
        }
    }

    private WaitForSeconds GetTypingSpeedWait(float speed)
    {
        // 캐시된 WaitForSeconds 객체 반환
        if (typingDelays.TryGetValue(speed, out WaitForSeconds wait))
        {
            return wait;
        }

        // 없으면 새로 생성하고 캐시
        wait = new WaitForSeconds(speed);
        typingDelays[speed] = wait;
        return wait;
    }

    private WaitForSeconds GetDisplayTimeWait(float time)
    {
        // 캐시된 WaitForSeconds 객체 반환
        if (displayTimeWaits.TryGetValue(time, out WaitForSeconds wait))
        {
            return wait;
        }

        // 없으면 새로 생성하고 캐시
        wait = new WaitForSeconds(time);
        displayTimeWaits[time] = wait;
        return wait;
    }

    private void Update()
    {
        if (isScrolling && scrollImage != null)
        {
            // 일정 속도로 위쪽으로 스크롤 (Y 값 증가)
            scrollY += scrollSpeed * Time.deltaTime;
            scrollImage.anchoredPosition = new Vector2(scrollImage.anchoredPosition.x, scrollY);

            // 스크롤 종료 조건 (끝점에 도달하면)
            if (scrollY > scrollEndY)
            {
                isScrolling = false;
                sequenceCompleted = true;
            }
        }
    }

    /// <summary>
    /// 인트로를 완료하고 다음 씬으로 이동
    /// </summary>
    private void CompleteIntro()
    {
        // 이미 전환 중인 경우 중복 실행 방지
        if (isTransitioning) return;
        isTransitioning = true;

        // BGM 페이드 아웃 (CrossfadeBGM을 통해)
        if (SoundManager.Instance != null && SoundManager.Instance.IsBGMPlaying("BGM_Intro"))
        {
            // 다음 씬으로 전환 전 배경음 페이드 아웃
            // SoundManager의 CrossfadeBGM이 내부 메서드이므로 직접 호출하지 않고
            // 다른 빈 사운드로 페이드하거나 볼륨 조절을 통해 처리
            SoundManager.Instance.SetBGMVolume(0f); // 볼륨을 0으로 설정하여 페이드 아웃 효과
        }

        // GameManager를 통해 타이틀씬으로 이동
        if (GameManager.Instance != null)
        {
            GameManager.Instance.CompleteIntro();
        }
        else
        {
            // GameManager가 없는 경우 직접 타이틀씬으로 이동
            SceneManager.LoadScene(1); // TitleScene 인덱스
        }
    }

    /// <summary>
    /// 인트로를 건너뛰고 타이틀씬으로 즉시 이동
    /// </summary>
    public void SkipIntro()
    {
        try
        {
            if (isTransitioning)
            {
                return;
            }

            isTransitioning = true;

            // 효과음 재생
            if (SoundManager.Instance != null)
            {
                SoundManager.Instance.PlaySound("Button_sfx", 0.5f, false);
                
            }

            // 진행 중인 코루틴 중단
            
            StopAllCoroutines();

            // 즉시 검은 화면으로 전환
            if (blackOverlay != null)
            {
                
                Color color = blackOverlay.color;
                color.a = initialPanelAlpha;
                blackOverlay.color = color;
            }

            // 다음 씬으로 전환 - 안전하게 코루틴으로 분리
            
            StartCoroutine(DirectCompleteIntro());
        }
        catch (System.Exception e)
        {
            Debug.LogError($"SkipIntro에서 예외 발생: {e.Message}\n{e.StackTrace}");

            // 마지막 시도로 직접 씬 로드
            try
            {
                
                SceneManager.LoadScene(1);
            }
            catch (System.Exception e2)
            {
                Debug.LogError($"직접 씬 로드 시도 중 예외 발생: {e2.Message}");
            }
        }
    }

    private IEnumerator DirectCompleteIntro()
    {
        yield return null; // 안전하게 1프레임 대기

        Debug.Log("DirectCompleteIntro 실행 중");

        // 인트로 BGM 페이드 아웃 (0.5초 동안)
        if (SoundManager.Instance != null && SoundManager.Instance.IsBGMPlaying("BGM_Intro"))
        {
            Debug.Log("인트로 BGM 페이드 아웃");
            SoundManager.Instance.FadeOutBGM(0.5f);
        }

        // 페이드 아웃이 완료될 때까지 약간 대기 (선택사항)
        yield return new WaitForSecondsRealtime(0.2f);  // 페이드 아웃의 일부만 기다리고 씬 전환

        try
        {
            // GameManager를 통한 씬 전환으로 수정
            if (GameManager.Instance != null)
            {
                Debug.Log("GameManager.CompleteIntro() 호출");
                GameManager.Instance.CompleteIntro();
            }
            else
            {
                // GameManager가 없는 경우에만 직접 씬 전환
                Debug.Log("GameManager 없음, 직접 타이틀씬으로 전환");
                SceneManager.LoadScene(1);
            }
        }
        catch (System.Exception e)
        {
            Debug.LogError($"씬 전환 중 예외 발생: {e.Message}\n{e.StackTrace}");

            // 마지막 시도로 직접 씬 로드
            try
            {
                SceneManager.LoadScene(1);
            }
            catch (System.Exception e2)
            {
                Debug.LogError($"최종 씬 로드 시도 중 예외 발생: {e2.Message}");
            }
        }
    }

    private void OnDestroy()
    {
        // 리소스 정리
        StopAllCoroutines();
        DOTween.Kill(transform);

        // 딕셔너리 정리
        typingDelays.Clear();
        displayTimeWaits.Clear();
    }
}

 

 

마찬가지로 인트로를 지나면 나오는 메인화면도 재밌게 개편하였다.

타이틀 글리치 이펙트와 별빛이 상승하는 듯한 효과도 Tween을 활용하였다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using DG.Tweening;

public class TitleSceneEffects : MonoBehaviour
{
    [Header("타이틀 이미지 설정")]
    [SerializeField] private RectTransform titleImage;
    [SerializeField] private float glitchInterval = 0.5f;
    [SerializeField] private float glitchDuration = 0.1f;
    [SerializeField] private float shakeStrength = 5f;
    [SerializeField] private int shakeVibrato = 10;
    [SerializeField] private float shakeRandomness = 90f;

    [Header("글리치 효과 설정")]
    [SerializeField] private float colorGlitchIntensity = 0.1f;
    [SerializeField] private float positionGlitchIntensity = 10f;
    [SerializeField] private bool useColorGlitch = true;
    [SerializeField] private bool usePositionGlitch = true;

    [Header("슬라이딩 이미지 설정")]
    [SerializeField] private GameObject slidingImagePrefab; // 슬라이딩 이미지 프리팹
    [SerializeField] private Transform slidingImagesParent; // 슬라이딩 이미지 부모 오브젝트
    [SerializeField] private bool isVerticalSlide = true; // true: 위/아래, false: 좌/우
    [SerializeField] private bool startFromTop = true; // true: 위에서 아래로, false: 아래서 위로
    [SerializeField] private bool startFromLeft = true; // true: 왼쪽에서 오른쪽으로, false: 오른쪽에서 왼쪽으로
    [SerializeField] private float slideDuration = 3f; // 이동에 걸리는 시간
    [SerializeField] private float spawnInterval = 2f; // 이미지 생성 간격
    [SerializeField] private int poolSize = 10; // 오브젝트 풀 크기

    [Header("이미지 효과 설정")]
    [SerializeField] private float minScale = 0.5f; // 최소 크기
    [SerializeField] private float maxScale = 1.5f; // 최대 크기
    [SerializeField] private float blinkChance = 0.3f; // 깜빡임 확률 (0-1)
    [SerializeField] private float blinkInterval = 0.1f; // 깜빡임 간격
    [SerializeField] private int maxBlinkCount = 5; // 최대 깜빡임 횟수

    // 컴포넌트 캐싱
    private Image titleImageComponent;
    private Color originalColor;
    private Vector2 originalPosition;

    // 시퀀스 저장용 변수
    private Sequence titleSequence;
    private Coroutine glitchCoroutine;
    private Coroutine spawnCoroutine;

    // 오브젝트 풀
    private List<RectTransform> slidingImagePool;
    private Queue<RectTransform> availableImages;
    private Canvas parentCanvas;

    void Awake()
    {
        // 캔버스 참조 얻기
        parentCanvas = GetComponentInParent<Canvas>();
        if (parentCanvas == null)
        {
            parentCanvas = GetComponent<Canvas>();
        }

        // 오브젝트 풀 초기화
        InitializeObjectPool();
    }

    void Start()
    {
        // 컴포넌트 초기화
        if (titleImage != null)
        {
            titleImageComponent = titleImage.GetComponent<Image>();
            if (titleImageComponent != null)
            {
                originalColor = titleImageComponent.color;
                originalPosition = titleImage.anchoredPosition;
            }
        }

        // 애니메이션 시작
        InitializeGlitchEffect();
        spawnCoroutine = StartCoroutine(SpawnSlidingImagesRoutine());
    }

    void OnDestroy()
    {
        // 시퀀스 정리
        titleSequence?.Kill();

        if (glitchCoroutine != null)
        {
            StopCoroutine(glitchCoroutine);
        }

        if (spawnCoroutine != null)
        {
            StopCoroutine(spawnCoroutine);
        }

        // 원래 상태로 복원
        if (titleImageComponent != null)
        {
            titleImageComponent.color = originalColor;
            titleImage.anchoredPosition = originalPosition;
        }

        // 활성화된 모든 슬라이딩 이미지 정리
        foreach (var img in slidingImagePool)
        {
            if (img != null && img.gameObject.activeSelf)
            {
                DOTween.Kill(img);
                img.gameObject.SetActive(false);
            }
        }
    }

    private void InitializeObjectPool()
    {
        if (slidingImagePrefab == null || slidingImagesParent == null)
        {
            Debug.LogError("슬라이딩 이미지 프리팹 또는 부모 오브젝트가 할당되지 않았습니다.");
            return;
        }

        slidingImagePool = new List<RectTransform>();
        availableImages = new Queue<RectTransform>();

        // 풀 사이즈에 맞게 모든 오브젝트 미리 생성
        for (int i = 0; i < poolSize; i++)
        {
            GameObject newObj = Instantiate(slidingImagePrefab, slidingImagesParent);
            RectTransform rectTransform = newObj.GetComponent<RectTransform>();

            if (rectTransform == null)
            {
                Debug.LogError("슬라이딩 이미지 프리팹에 RectTransform 컴포넌트가 없습니다.");
                continue;
            }

            // 이미지 컴포넌트 캐싱 (깜빡임 효과용)
            Image imageComponent = newObj.GetComponent<Image>();
            if (imageComponent == null)
            {
                Debug.LogWarning("슬라이딩 이미지 프리팹에 Image 컴포넌트가 없습니다. 깜빡임 효과가 적용되지 않습니다.");
            }

            newObj.name = "SlidingImage_" + i;
            newObj.SetActive(false);
            slidingImagePool.Add(rectTransform);
            availableImages.Enqueue(rectTransform);
        }

        Debug.Log($"슬라이딩 이미지 풀 초기화 완료: {poolSize}개 생성됨");
    }

    private RectTransform GetSlidingImageFromPool()
    {
        if (availableImages.Count == 0)
        {
            // 모든 오브젝트가 사용 중이면 가장 오래된 것을 재활용
            RectTransform oldestImage = slidingImagePool[0];
            DOTween.Kill(oldestImage); // 기존 애니메이션 정리
            oldestImage.gameObject.SetActive(false);
            return oldestImage;
        }

        RectTransform image = availableImages.Dequeue();
        image.gameObject.SetActive(true);
        return image;
    }

    private void ReturnImageToPool(RectTransform image)
    {
        image.gameObject.SetActive(false);
        availableImages.Enqueue(image);
    }

    private void InitializeGlitchEffect()
    {
        if (titleImage == null) return;

        // 글리치 코루틴 시작
        glitchCoroutine = StartCoroutine(GlitchEffectRoutine());

        // 흔들림 효과를 위한 시퀀스 생성 - 순수하게 위치 흔들림만 적용
        titleSequence = DOTween.Sequence();

        // 지속적인 작은 흔들림 효과
        titleSequence.Append(
            titleImage.DOShakePosition(
                duration: 1.5f,
                strength: new Vector3(shakeStrength * 0.7f, shakeStrength * 0.3f, 0),
                vibrato: shakeVibrato,
                randomness: shakeRandomness,
                snapping: false,
                fadeOut: true
            )
        ).SetLoops(-1, LoopType.Restart);
    }

    private IEnumerator GlitchEffectRoutine()
    {
        WaitForSeconds glitchWait = new WaitForSeconds(glitchDuration);
        WaitForSeconds intervalWait = new WaitForSeconds(glitchInterval - glitchDuration);

        while (true)
        {
            // 글리치 효과 적용
            ApplyGlitchEffect(true);
            yield return glitchWait;

            // 원래 상태로 복원
            ApplyGlitchEffect(false);
            yield return intervalWait;
        }
    }

    private void ApplyGlitchEffect(bool apply)
    {
        if (titleImageComponent == null) return;

        if (apply)
        {
            // 1. 색상 글리치
            if (useColorGlitch)
            {
                Color glitchColor = new Color(
                    originalColor.r + Random.Range(-colorGlitchIntensity, colorGlitchIntensity),
                    originalColor.g + Random.Range(-colorGlitchIntensity, colorGlitchIntensity),
                    originalColor.b + Random.Range(-colorGlitchIntensity, colorGlitchIntensity),
                    originalColor.a
                );
                titleImageComponent.color = glitchColor;
            }

            // 2. 위치 글리치 - DOTween 흔들림과 별개의 추가 효과
            if (usePositionGlitch)
            {
                Vector2 glitchPosition = new Vector2(
                    originalPosition.x + Random.Range(-positionGlitchIntensity, positionGlitchIntensity),
                    originalPosition.y + Random.Range(-positionGlitchIntensity, positionGlitchIntensity)
                );
                titleImage.anchoredPosition = glitchPosition;
            }
        }
        else
        {
            // 원래 상태로 색상만 복원 (위치는 DOTween에서 처리)
            titleImageComponent.color = originalColor;

            // 위치 글리치를 적용했을 경우, 위치도 복원
            if (usePositionGlitch)
            {
                titleImage.anchoredPosition = originalPosition;
            }
        }
    }

    private IEnumerator SpawnSlidingImagesRoutine()
    {
        WaitForSeconds wait = new WaitForSeconds(spawnInterval);

        while (true)
        {
            SpawnSlidingImage();
            yield return wait;
        }
    }

    private void SpawnSlidingImage()
    {
        if (slidingImagePool.Count == 0 || availableImages.Count == 0 || parentCanvas == null) return;

        RectTransform imageRect = GetSlidingImageFromPool();
        Image imageComponent = imageRect.GetComponent<Image>();

        // 화면 크기 계산 (캔버스의 RectTransform 사용)
        RectTransform canvasRect = parentCanvas.GetComponent<RectTransform>();
        float canvasWidth = canvasRect.rect.width;
        float canvasHeight = canvasRect.rect.height;

        // 랜덤 스케일 적용
        float randomScale = Random.Range(minScale, maxScale);
        imageRect.localScale = new Vector3(randomScale, randomScale, 1f);

        // 시작 및 끝 위치 계산
        Vector2 startPos, endPos;

        // X 위치에 약간의 랜덤성 추가
        float randomXOffset = Random.Range(-canvasWidth * 0.3f, canvasWidth * 0.3f);
        // Y 위치에 약간의 랜덤성 추가
        float randomYOffset = Random.Range(-canvasHeight * 0.3f, canvasHeight * 0.3f);

        if (isVerticalSlide)
        {
            // 수직 이동 (위에서 아래 또는 아래서 위)
            if (startFromTop)
            {
                // 위에서 시작
                startPos = new Vector2(randomXOffset, canvasHeight / 2 + 100);
                endPos = new Vector2(randomXOffset, -canvasHeight / 2 - 100);
            }
            else
            {
                // 아래서 시작
                startPos = new Vector2(randomXOffset, -canvasHeight / 2 - 100);
                endPos = new Vector2(randomXOffset, canvasHeight / 2 + 100);
            }
        }
        else
        {
            // 수평 이동 (왼쪽에서 오른쪽 또는 오른쪽에서 왼쪽)
            if (startFromLeft)
            {
                // 왼쪽에서 시작
                startPos = new Vector2(-canvasWidth / 2 - 100, randomYOffset);
                endPos = new Vector2(canvasWidth / 2 + 100, randomYOffset);
            }
            else
            {
                // 오른쪽에서 시작
                startPos = new Vector2(canvasWidth / 2 + 100, randomYOffset);
                endPos = new Vector2(-canvasWidth / 2 - 100, randomYOffset);
            }
        }

        // 시작 위치 설정
        imageRect.anchoredPosition = startPos;

        // 이동 시퀀스 생성
        Sequence slideSequence = DOTween.Sequence();

        // 직선 이동 (sway 없이)
        slideSequence.Append(
            imageRect.DOAnchorPos(endPos, slideDuration).SetEase(Ease.Linear)
        );

        // 깜빡임 효과 추가 (랜덤하게) - 즉시 알파값 변경 방식으로 수정
        if (imageComponent != null && Random.value < blinkChance)
        {
            // 몇 번 깜빡일지 랜덤하게 결정
            int blinkCount = Random.Range(2, maxBlinkCount);

            for (int i = 0; i < blinkCount; i++)
            {
                // 랜덤한 시간에 깜빡임 발생
                float blinkTime = Random.Range(slideDuration * 0.1f, slideDuration * 0.9f);

                // 순간적으로 알파값 0으로 변경 (Ease.INTERNAL_Zero = 즉시 변경)
                slideSequence.InsertCallback(blinkTime, () => {
                    Color tempColor = imageComponent.color;
                    tempColor.a = 0f;
                    imageComponent.color = tempColor;
                });

                // 순간적으로 알파값 1로 복구 (즉시 복구)
                slideSequence.InsertCallback(blinkTime + blinkInterval, () => {
                    Color tempColor = imageComponent.color;
                    tempColor.a = 1f;
                    imageComponent.color = tempColor;
                });
            }
        }

        // 완료 후 오브젝트 풀에 반환
        slideSequence.OnComplete(() => ReturnImageToPool(imageRect));
    }

    // 인스펙터에서 효과 재시작 버튼용
    public void RestartEffects()
    {
        // 기존 효과 정리
        if (glitchCoroutine != null)
        {
            StopCoroutine(glitchCoroutine);
        }

        if (spawnCoroutine != null)
        {
            StopCoroutine(spawnCoroutine);
        }

        titleSequence?.Kill();

        // 원래 상태로 복원
        if (titleImageComponent != null)
        {
            titleImageComponent.color = originalColor;
            titleImage.anchoredPosition = originalPosition;
        }

        // 활성화된 모든 슬라이딩 이미지 정리
        foreach (var img in slidingImagePool)
        {
            if (img != null && img.gameObject.activeSelf)
            {
                DOTween.Kill(img);
                img.gameObject.SetActive(false);
                availableImages.Enqueue(img);
            }
        }

        // 효과 다시 시작
        glitchCoroutine = StartCoroutine(GlitchEffectRoutine());
        InitializeGlitchEffect();
        spawnCoroutine = StartCoroutine(SpawnSlidingImagesRoutine());
    }

    // 외부에서 효과 설정을 변경할 수 있는 메서드들
    public void SetGlitchIntensity(float colorIntensity, float positionIntensity)
    {
        colorGlitchIntensity = colorIntensity;
        positionGlitchIntensity = positionIntensity;
    }

    public void SetGlitchInterval(float interval, float duration)
    {
        glitchInterval = Mathf.Max(0.1f, interval);
        glitchDuration = Mathf.Min(duration, glitchInterval);

        if (glitchCoroutine != null)
        {
            StopCoroutine(glitchCoroutine);
            glitchCoroutine = StartCoroutine(GlitchEffectRoutine());
        }
    }

    public void SetSlideDirection(bool vertical, bool fromTopOrLeft)
    {
        isVerticalSlide = vertical;
        if (vertical)
        {
            startFromTop = fromTopOrLeft;
        }
        else
        {
            startFromLeft = fromTopOrLeft;
        }
    }

    public void SetSpawnRate(float interval)
    {
        spawnInterval = interval;

        if (spawnCoroutine != null)
        {
            StopCoroutine(spawnCoroutine);
            spawnCoroutine = StartCoroutine(SpawnSlidingImagesRoutine());
        }
    }

    public void SetScaleRange(float min, float max)
    {
        minScale = Mathf.Max(0.1f, min);
        maxScale = Mathf.Max(minScale, max);
    }

    public void SetBlinkEffect(float chance, float interval)
    {
        blinkChance = Mathf.Clamp01(chance);
        blinkInterval = Mathf.Max(0.01f, interval);
    }
}

 

 

Tween과 코루틴, 적절한 함수를 활용하면 애니메이션이나 화려한 그래픽 리소스가 없어도 괜찮은 느낌을 줄 수 있어
앞으로도 잘 활용할 것 같다.

2025-03-20 20:34:05

그 동안의 개발한 일지의 업데이트

여태까지 적 캐릭터들의 스폰 방식은 플레이어를 중심으로 하여 정해진 범위 안에서 랜덤한 위치에 스폰되어 플레이어를 추적했다.

맵은 무한히 동적으로 커지는 방식이어서 플레이어는 적을 피해 잘 도망다닐 수 있었고, 이로 인해 난이도가 너무 쉬워진 느낌을 받았다.

그리고 모바일 환경에서 동적으로 무한히 커지는 맵 방식은 최적화에도 그리 좋지 않았다.

 

그렇게 앞으로의 레벨 디자인과 성능을 위해 Brotato 라는 게임처럼 유한한 맵 속에서

정해진 웨이브에 따라 적이 등장하고,

웨이브 시간 내에 생존하여 다음 웨이브를 위해 스펙업을 하는 형식으로 변경하게 되었다.

 

그리고 유한한 맵일지라도 플레이어를 게임오버의 위기로 넣기에는 부족하여

적들이 시간마다 스폰하는 일종의 포메이션을 만들어

플레이어가 적들 속에 갖히게 되는 상황을 연출하기 위해

웨이브 데이터 구조를 크게 변경하였다.

웨이브 데이터 SO 클래스에 커스텀 에디터 클래스를 추가하여 인스펙터에서 각 웨이브마다 어떠한 형태로 스폰되게 할지 구현하였다.

Surround로 설정 시 플레이어가 원형으로 스폰된 적들 사이에 갖히게 된다.

하지만 인스펙터 창에서 변수 수정만으로는 적들이 어떤식으로 스폰될 지 직관적으로 알 수가 없었다.

그래서 각 포메이션마다 설정한 변수 값에 따라 적들이 어떤 식으로 스폰될지 간략하게 인스펙터에서 확인 할 수 있게 구현했고

   public override void OnInspectorGUI()
   {
       DrawDefaultInspector();

       WaveData waveData = (WaveData)target;

       EditorGUILayout.Space();
       EditorGUILayout.LabelField("Preview Tools", EditorStyles.boldLabel);

       EditorGUILayout.BeginHorizontal();
       previewWaveNumber = EditorGUILayout.IntField("Wave Number", previewWaveNumber);

       if (GUILayout.Button("Preview Wave"))
       {
           waveData.PreviewWave(previewWaveNumber);
       }
       EditorGUILayout.EndHorizontal();

       // 스폰 포메이션 미리보기 추가
       EditorGUILayout.Space();
       EditorGUILayout.LabelField("Spawn Formation Preview", EditorStyles.boldLabel);

       EditorGUILayout.BeginHorizontal();
       simulatedPlayerPosition = EditorGUILayout.Vector2Field("Player Position", simulatedPlayerPosition);
       showSpawnPreview = EditorGUILayout.Toggle("Show Preview", showSpawnPreview);
       EditorGUILayout.EndHorizontal();

       if (GUILayout.Button("Generate Preview Points"))
       {
           GeneratePreviewPoints(waveData);
       }

       if (showSpawnPreview && previewPositions.Count > 0)
       {
           DrawPreviewGrid();
       }
   }

   private void DrawPreviewGrid()
   {
       // 미리보기 그리드를 그리기 위한 간단한 레이아웃
       float gridSize = 200f;
       Rect gridRect = GUILayoutUtility.GetRect(gridSize, gridSize);

       // 미리보기 그리드 그리기
       Handles.BeginGUI();

       // 배경
       EditorGUI.DrawRect(gridRect, new Color(0.2f, 0.2f, 0.2f));

       // 격자
       Handles.color = new Color(0.3f, 0.3f, 0.3f);
       float cellSize = 20f;
       for (float x = 0; x <= gridSize; x += cellSize)
       {
           Handles.DrawLine(
               new Vector3(gridRect.x + x, gridRect.y),
               new Vector3(gridRect.x + x, gridRect.y + gridSize)
           );
       }
       for (float y = 0; y <= gridSize; y += cellSize)
       {
           Handles.DrawLine(
               new Vector3(gridRect.x, gridRect.y + y),
               new Vector3(gridRect.x + gridSize, gridRect.y + y)
           );
       }

       // 중앙(플레이어 위치) 표시
       Vector2 center = new Vector2(gridRect.x + gridSize / 2, gridRect.y + gridSize / 2);
       float playerSize = 10f;
       Handles.color = Color.white;
       Handles.DrawSolidDisc(center, Vector3.forward, playerSize / 2);

       // 스폰 포인트 그리기
       float scale = gridSize / 30f; // 30x30 단위를 그리드에 맞게 스케일링

       for (int i = 0; i < previewPositions.Count; i++)
       {
           Vector2 pos = previewPositions[i];
           Vector2 screenPos = center + new Vector2(pos.x * scale, -pos.y * scale); // y축 반전

           Color pointColor = previewColors[i % previewColors.Length];
           Handles.color = pointColor;

           // 스폰 포인트 원
           Handles.DrawSolidDisc(screenPos, Vector3.forward, 5f);

           // 번호 표시
           GUIStyle style = new GUIStyle();
           style.normal.textColor = Color.black;
           style.alignment = TextAnchor.MiddleCenter;
           style.fontStyle = FontStyle.Bold;

           Handles.Label(screenPos, (i + 1).ToString(), style);
       }

       Handles.EndGUI();

       // 범례 표시
       EditorGUILayout.BeginHorizontal();
       EditorGUILayout.LabelField("Preview Scale: 1 unit = " + (1 / scale).ToString("F1") + " game units");
       EditorGUILayout.EndHorizontal();
   }

   private void GeneratePreviewPoints(WaveData waveData)
   {
       previewPositions.Clear();

       // 선택한 웨이브 찾기
       WaveData.Wave wave = waveData.GetWave(previewWaveNumber);
       if (wave == null)
       {
           Debug.LogWarning($"Wave {previewWaveNumber} not found!");
           return;
       }

       // 스폰 설정 가져오기
       SpawnSettings settings = wave.spawnSettings;
       int count = wave.spawnAmount;

       // 포메이션에 따라 미리보기 포인트 생성
       switch (settings.formation)
       {
           case SpawnFormation.Surround:
               GenerateSurroundPreviewPoints(count, settings);
               break;
           case SpawnFormation.Rectangle:
               GenerateRectanglePreviewPoints(count, settings);
               break;
           case SpawnFormation.Line:
               GenerateLinePreviewPoints(count, settings);
               break;
           case SpawnFormation.Random:
               GenerateRandomPreviewPoints(count);
               break;
           case SpawnFormation.EdgeRandom:
               GenerateEdgeRandomPreviewPoints(count);
               break;
           case SpawnFormation.Fixed:
               // 고정 스폰 포인트는 여기서 미리보기 생성하지 않음
               break;
       }
   }

설정한 웨이브 넘버의 인덱스를 입력하고 프리뷰를 할 수 있어

적들이 어떤식으로 스폰될 지 시각적으로 예측할 수 있게 되어 더욱 편리해졌다.

앞으로 플레이어가 해당 레벨이 요구하는 스펙에 맞지 않는다면 위기 상황이 자주 연출 될 것이다.

 

위 움짤에서 볼 수 있는 새롭게 추가된 적 처치 이펙트가 있다.

처음에는 파티클 시스템을 추가할까 하다가 더 좋은 방법이 없나 찾아보았고

DOTween을 이용하기로 했다.

using UnityEngine;
using System.Collections;
using DG.Tweening;

public class EnemyDeathEffect : MonoBehaviour
{
    [Header("폭발 설정")]
    [SerializeField] private int particleCount = 5;
    [SerializeField] private float explosionDuration = 0.5f;
    [SerializeField] private float explosionRadius = 1f;
    [SerializeField] private Vector2 particleSizeRange = new Vector2(0.1f, 0.3f);

    [Header("파티클 설정")]
    [SerializeField]
    private Color[] particleColors = new Color[]
    {
        new Color(1f, 0f, 0f),      // 빨강
        new Color(0f, 0f, 0f),      // 검정
        new Color(65/255f, 65/255f, 65/255f)  // 회색
    };

    [Header("오브젝트 풀 설정")]
    [SerializeField] private string effectPoolTag = "DeathParticle"; // 풀 태그명은 GameManager와 일치해야 함

    // 파티클 재사용 설정
    private static readonly int maxConcurrentEffects = 3; // 동시에 발생 가능한 최대 효과 수
    private static int activeEffectCount = 0;

    // 캐싱을 위한 변수
    private static readonly WaitForSeconds particleDelay = new WaitForSeconds(0.02f);

    // 생성자에서 정적 필드 초기화 방지 (성능 최적화)
    static EnemyDeathEffect() { }

    // 몬스터가 죽을 때 호출될 메서드
    public void PlayDeathEffect(Vector3 position)
    {
        // 최대 동시 효과 수 제한 확인
        if (activeEffectCount >= maxConcurrentEffects)
            return;

        // 풀 존재 확인 - GameManager에서 이미 초기화했으므로 확인만 함
        if (ObjectPool.Instance == null || !ObjectPool.Instance.DoesPoolExist(effectPoolTag))
        {
            Debug.LogWarning($"DeathParticle pool not found. Skipping effect.");
            return;
        }

        // 효과 실행
        StartCoroutine(CreateDeathEffect(position));
    }

    private IEnumerator CreateDeathEffect(Vector3 position)
    {
        activeEffectCount++;

        for (int i = 0; i < particleCount; i++)
        {
            // 오브젝트 풀에서 파티클 가져오기
            GameObject particle = ObjectPool.Instance.SpawnFromPool(effectPoolTag, position, Quaternion.identity);
            if (particle != null)
            {
                ConfigureAndAnimateParticle(particle, position);
            }

            // 시간차를 두고 파티클 생성
            yield return particleDelay;
        }

        // 모든 파티클이 애니메이션을 완료하기 위한 충분한 시간 대기
        yield return new WaitForSeconds(explosionDuration);

        activeEffectCount--;
    }

    private void ConfigureAndAnimateParticle(GameObject particle, Vector3 position)
    {
        // 렌더러 가져오기
        SpriteRenderer renderer = particle.GetComponent<SpriteRenderer>();
        if (renderer == null)
        {
            Debug.LogWarning("Particle is missing SpriteRenderer component");
            return;
        }

        // 랜덤 설정
        float size = Random.Range(particleSizeRange.x, particleSizeRange.y);
        Color color = particleColors[Random.Range(0, particleColors.Length)];
        float angle = Random.Range(0f, 360f);
        float distance = explosionRadius * Random.Range(0.5f, 1f);

        // 렌더러 설정
        renderer.color = color;

        // 목표 위치 계산
        Vector3 targetPos = position + new Vector3(
            Mathf.Cos(angle * Mathf.Deg2Rad) * distance,
            Mathf.Sin(angle * Mathf.Deg2Rad) * distance,
            0f
        );

        // 기존 DOTween 애니메이션 제거
        DOTween.Kill(particle.transform);
        DOTween.Kill(renderer);

        // DOTween 애니메이션
        Sequence seq = DOTween.Sequence();

        // 초기 설정
        particle.transform.localScale = Vector3.zero;
        renderer.color = new Color(color.r, color.g, color.b, 1f);

        // 크기 조정
        seq.Append(particle.transform.DOScale(new Vector3(size, size, 1f), explosionDuration * 0.2f));

        // 이동
        seq.Join(particle.transform.DOMove(targetPos, explosionDuration)
            .SetEase(Ease.OutQuad));

        // 회전
        seq.Join(particle.transform.DORotate(
            new Vector3(0f, 0f, Random.Range(-180f, 180f)),
            explosionDuration,
            RotateMode.FastBeyond360
        ).SetEase(Ease.OutQuad));

        // 페이드 아웃
        seq.Join(renderer.DOFade(0f, explosionDuration)
            .SetEase(Ease.InQuad));

        // 완료 후 오브젝트 풀로 반환
        seq.OnComplete(() => {
            if (ObjectPool.Instance != null)
            {
                ObjectPool.Instance.ReturnToPool(effectPoolTag, particle);
            }
        });

        // 애니메이션이 중간에 중단될 경우를 대비한 안전장치
        seq.SetUpdate(true); // TimeScale에 영향받지 않도록 설정
    }

    private void OnDestroy()
    {
        // 진행 중인 모든 DOTween 애니메이션 종료
        DOTween.Kill(transform);
    }
}

 

DOTween에서 지원하는 함수 덕분에 간단하게 파티클 처럼 이펙트를 만들 수 있었고 오브젝트 풀링을 적용하여

성능면에서도 효율적으로 구현할 수 있었다.

 

기왕 DOTween을 이용하는 김에 UI에도 적용하였다.

using UnityEngine;
using DG.Tweening;
using UnityEngine.UI;
using System;

public class ScreenTransitionEffect : MonoBehaviour
{
    [Header("트랜지션 설정")]
    public float transitionDuration = 0.5f;
    public Ease scaleEase = Ease.InOutQuad;
    public bool useBlackScreen = true;

    [Header("선택적 설정")]
    public bool autoRevert = false; // 이펙트 나오고서 바로 reverDelay후에 다시 효과 반전되서 그대로 출력되게 할것인지
    public float revertDelay = 0.2f;
    public bool reverseEffect = false; // 효과 반전 여부 결정

    private RectTransform rectTransform;
    private Image image;
    private Vector3 originalScale;

    private void Awake()
    {
        rectTransform = GetComponent<RectTransform>();
        image = GetComponent<Image>();
        originalScale = rectTransform.localScale;

        
        gameObject.SetActive(false);
    }

    // 테스트 코드 제거 - Update 메서드 전체 삭제

    public void PlayTransition(Action onTransitionComplete = null)
    {
        Debug.Log("PlayTransition called");

        // 기존 Tween 종료
        DOTween.Kill(rectTransform);

        // 초기 상태로 설정
        if (reverseEffect)
        {
            // 반전 효과: X 스케일이 0에서 시작
            rectTransform.localScale = new Vector3(0, originalScale.y, originalScale.z);
        }
        else
        {
            // 원래 효과: 기본 크기에서 시작
            rectTransform.localScale = originalScale;
        }

        gameObject.SetActive(true);

        // 검은색 이미지를 사용하는 경우
        if (useBlackScreen && image != null)
        {
            image.color = Color.black;
        }

        if (reverseEffect)
        {
            // 반전 효과: X 스케일이 0에서 원래 크기로 커짐
            rectTransform.DOScaleX(originalScale.x, transitionDuration)
                .SetEase(scaleEase)
                .SetUpdate(true) // 타임스케일 영향 받지 않게
                .OnComplete(() => {
                    // 트랜지션 완료 후 콜백 호출
                    onTransitionComplete?.Invoke();

                    // 여기에 비활성화 코드 추가
                    gameObject.SetActive(false);

                    // 자동 복구가 활성화된 경우
                    if (autoRevert)
                    {
                        DOVirtual.DelayedCall(revertDelay, () => {
                            RevertTransition();
                        }).SetUpdate(true);
                    }
                });
        }
        else
        {
            // 원래 효과: X 스케일이 원래 크기에서 0으로 줄어듦
            rectTransform.DOScaleX(0, transitionDuration)
                .SetEase(scaleEase)
                .SetUpdate(true) // 타임스케일 영향 받지 않게
                .OnComplete(() => {
                    // 트랜지션 완료 후 콜백 호출
                    onTransitionComplete?.Invoke();

                    // 여기에 비활성화 코드 추가
                    gameObject.SetActive(false);

                    // 자동 복구가 활성화된 경우
                    if (autoRevert)
                    {
                        DOVirtual.DelayedCall(revertDelay, () => {
                            RevertTransition();
                        }).SetUpdate(true);
                    }
                });
        }
    }
    public void RevertTransition(Action onRevertComplete = null)
    {
        if (reverseEffect)
        {
            // 반전 효과의 복구: X 스케일이 원래 크기에서 0으로 줄어듦
            rectTransform.DOScaleX(0, transitionDuration)
                .SetEase(scaleEase)
                .OnComplete(() => {
                    onRevertComplete?.Invoke();
                    gameObject.SetActive(false); // 트랜지션 완료 후 비활성화
                });
        }
        else
        {
            // 원래 효과의 복구: X 스케일이 0에서 원래 크기로 커짐
            rectTransform.DOScaleX(originalScale.x, transitionDuration)
                .SetEase(scaleEase)
                .OnComplete(() => {
                    onRevertComplete?.Invoke();
                    gameObject.SetActive(false); // 트랜지션 완료 후 비활성화
                });
        }
    }

    private void OnDisable()
    {
        DOTween.Kill(rectTransform);
    }
}

 

간단한 이미지 트랜지션을 통해 UI 전환을 좀 더 자연스럽게 연출할 수 있었다.

 

2025-03-06 23:42:07

어떻게 하면 더 성능을 향상 시킬 수 있을 지 고민하는 중 코드를 살펴보니

각종 초기화, 오브젝트 풀 활성화 , 각종 리소스 로드 등등의 호출 시기가 제각각인 것을 떠올렸고,

이들의 가비지 컬렉션 타이밍, 초기화 순서를 제어함으로서 안정성과 메모리 사용량을 최적화해야겠다고 생각했다.

 

그렇게 큰 게임이 아니라서 초기에 로딩화면을 굳이 구현해야하나라고 생각하여 스킵하고,

다른 기능 구현에 급급하다보니 정작 가장 중요한 것을 빼먹은 셈이다.

로딩 화면 및 로딩 기능의 이점

  1. 사용자 경험 향상
    • 게임이 멈춰있다는 인상을 주지 않고, 진행 중이라는 피드백 제공
    • 로딩 진행률, 팁 메시지 등으로 사용자에게 유용한 정보 제공
  2. 리소스 초기화 최적화
    • 필요한 리소스를 미리 로드하여 게임 실행 중 끊김 현상 방지
    • 오브젝트 풀링으로 인스턴스화 비용 분산
    • 비동기 로딩으로 메인 스레드 블로킹 방지
  3. 메모리 관리 개선
    • 불필요한 리소스 해제로 메모리 사용량 최적화
    • 가비지 컬렉션 타이밍 제어
    • 씬 전환 시 메모리 누수 방지
  4. 안정성 증가
    • 초기화 순서 제어로 참조 오류 방지
    • 비정상 종료나 예외 상황에 대한 대응 가능
    • 필요한 모든 시스템이 준비된 후에만 게임 시작
  5. 모바일 환경 최적화
    • 제한된 하드웨어 자원 환경에서 리소스 로딩 분산
    • 초기 로딩 시간 투자로 인게임 성능 향상
    • 배터리 효율성 향상

PC와 다르게 모바일 환경에서는 리소스 관리가 아주 중요하다.

배터리 발열부터해서 fps와 같은 플레이 경험이 PC 환경보다 확 와닫는 느낌이라서 그런것 같다.

 

이미 개발이 많이 진행된 게임에서 중간에 로딩 기능을 만드는 사람이 어딨을까..

이런 실수들을 통해 다음에는 하지 않으리라 생각하며 부딪혔다.

 

우선 로딩이라는 게임 상태를 추가했고

 /// <summary>
 /// 로딩 프로세스를 시작합니다. 로딩 씬에서 호출됩니다.
 /// </summary>
 public void StartLoadingProcess()
 {
     StartCoroutine(LoadGameCoroutine());
 }

 private IEnumerator LoadGameCoroutine()
 {
     float startTime = Time.time;

     // 1. 초기화 작업 수행
     yield return StartCoroutine(PerformInitializationSteps());

     if (isLoadingCancelled)
     {
         OnLoadingCancelled?.Invoke();
         yield break;
     }

     // 2. 전투 씬 비동기 로드
     int combatSceneIndex;
     if (!gameScene.TryGetValue(GameState.Playing, out combatSceneIndex))
     {
         combatSceneIndex = 2; // 기본값
     }

     currentSceneLoadOperation = SceneManager.LoadSceneAsync(combatSceneIndex);
     currentSceneLoadOperation.allowSceneActivation = false; // 로딩이 완료되어도 바로 활성화하지 않음

     // 씬 로딩 진행률 업데이트 (90% -> 100%)
     while (currentSceneLoadOperation.progress < 0.9f)
     {
         LoadingProgress = 0.9f + (currentSceneLoadOperation.progress / 10f);
         yield return null;

         if (isLoadingCancelled)
         {
             OnLoadingCancelled?.Invoke();
             yield break;
         }
     }

     // 3. 최소 로딩 시간 보장
     float elapsedTime = Time.time - startTime;
     if (elapsedTime < minimumLoadingTime)
     {
         yield return new WaitForSeconds(minimumLoadingTime - elapsedTime);
     }

     // 4. 로딩 완료
     LoadingProgress = 1.0f;

     // 로딩 완료 이벤트 발생
     OnLoadingCompleted?.Invoke();

     // 5. 씬 활성화
     SetGameState(GameState.Playing);
     currentSceneLoadOperation.allowSceneActivation = true;

     // 6. 게임 시작 음악 재생
     var soundManager = SoundManager.Instance;
     if (soundManager != null && !soundManager.IsBGMPlaying("BGM_Battle"))
     {
         soundManager.LoadSoundBank("CombatSoundBank");
         soundManager.PlaySound("BGM_Battle", 1f, true);
     }
 }

 

코루틴을 이용해 로딩 프로세스를 시작,

  // 모든 초기화 단계를 수행하는 코루틴
  private IEnumerator PerformInitializationSteps()
  {
      // 1. 사운드 시스템 초기화 (0% -> 10%)
      InitializeCombatSound(); // 전투용 사운드 준비
      LoadingProgress = 0.1f;
      yield return InitializationDelay;

      if (isLoadingCancelled) yield break;

      // 2. 오브젝트 풀 초기화 (10% -> 25%)
      yield return InitializeObjectPools();
      LoadingProgress = 0.25f;

      if (isLoadingCancelled) yield break;

      // 3. 게임 리소스 로드 (25% -> 50%)
      yield return PreloadGameResources();
      LoadingProgress = 0.5f;

      if (isLoadingCancelled) yield break;

      // 4. 전투 시스템 준비 (50% -> 75%)
      yield return PrepareCombatSystem();
      LoadingProgress = 0.75f;

      if (isLoadingCancelled) yield break;

      // 5. 최종 준비 (75% -> 90%)
      yield return FinalizeInitialization();
      LoadingProgress = 0.9f;
  }

 

단계별로 초기화 및 로드가 진행되면 LoadingProgress에 따라 시각적인 로딩바가 움직이게 했다.

 

전체적인 로딩 프로세스 흐름은 이렇다 :

로딩 프로세스 흐름

  1. GameManager.StartGame() 호출
  2. 로딩 상태로 전환 및 로딩 씬 로드
  3. LoadingSceneController가 초기화되고 GameManager.StartLoadingProcess() 호출
  4. GameManager가 LoadGameCoroutine()을 시작하여 다음 단계 수행:
    • 초기화 작업 (오브젝트 풀, 사운드, 리소스 등)
    • 게임 씬 비동기 로드
    • 최소 로딩 시간 보장
    • 로딩 완료 이벤트 발생
  5. LoadingSceneController가 로딩 완료 이벤트를 받아 처리
  6. 게임 플레이 상태로 전환 및 게임 씬 활성화

구현된 모습

 

타겟 fps에 들어온 모습을 확인으로 최적화에 많은 변화가 있었다~

 

2025-03-05 21:35:51

또또 포스트가 늦어졌다...

결과부터 말하자면 기존에 목표로 잡았던 타겟 기기에서의 목표 평균 fps를 달성하는데 시간을 들였다.

 

달성하기까지의 과정을 간략하게 단계별로 남기려고 한다.

 

최적화가 시급하구나라고 느낀건 대충 게임 사이클이 완성되고 빌드를 한 후에 실제로 모바일 기기에서 플레이를 했을 때,

에디터에서 플레이했을 때와 너무나도 다른 게임 경험이 느껴졌다.

 

물론 PC 사양이 훨씬 좋아서 그런것도 있지만, 그래픽이 픽셀인 게임인 데다가, 최대한 심플한 컨셉이기도 하고,

그렇게 많은 물량의 적들이 쏟아져 나온다던가, 복잡한 구조를 가진 게임이 아닌데 모바일 기기에서 플레이했을 때

굉장히 불편하다고 느낄 정도로 평균 프레임이 나오지 않는다는 것을 바로 느낄 수 있었다.

 

그래서 현재 사용중인 모바일 기기를 에디터와 연결하여 플레이 및 프로파일러를 켜보았다.

 

...보고 충격먹었다.

그동안 구현에만 치중하고 몰두해있다보니 최적화를 전혀 신경쓰지 않았구나,

가장 기본중의 기본을 놓친 채로 개발하고 있었구나라는 생각이 들자마자

진행 중인 모든 기획, 개발을 멈춘 채로 최적화 작업을 시작하였다.

 

우선 프로파일러를 봤을 때 CPU의 사용량을 가장 많이 차지하는 부분은 렌더링 부분이었다.

Batches Count 수는 평균 33으로 그렇게 많은 편은 아니었고

PlayerLoop을 확인했을 때 가장 많이 차지하는 작업은

 

 

  • PostLateUpdate.FinishFrameRendering: 99.7% (33.21ms)
  • DirectorUpdateAnimationBegin: 79.3% (26.43ms)
  • DirectorUpdateAnimationEnd: 8.4% (2.82ms)
  • Physics2DFixedUpdate: 4.9% (1.65ms)

FinishFrameRendering이 전체 프레임 시간의 대부분을 차지하고 있다.

이는 렌더링 파이프라인에서 병목이 발생하고 있다는 신호이다.

Animation 관련 작업도 상당한 시간을 소비하고 있다.

 

이를 해결하기 위해 다음과 같은 방법을 시도하였다 : 

 

1. 현재 사용 중인 렌더링 파이프라인(URP/Built-in)과 설정

2. 스프라이트 아틀라스 최적화

3. 애니메이션 최적화

 

 

 

우선 첫 번째 URP 설정을 확인해보았다.

 

 

 

눈 여겨봐야할 설정은

HDR, Shadow Resolution, Additional Lights, Lens Flare, Render Scale

 

현재 설정에서 HDR 설정이 활성화 되어있는데, 모바일 기기에서는 불필요하게 리소스를 소모한다고 한다.

 

여기서 HDR 이란

HDR(High Dynamic Range)은 표준 8비트(0-255) 색상 범위를 넘어서는 더 넓은 범위의 밝기와 색상을 처리할 수 있게 해주는 기술입니다. 기본적으로 어두운 영역은 더 어둡게, 밝은 영역은 더 밝게 표현할 수 있습니다.,,라고 한다.

 

이게 성능과 무슨 관계가 있냐고 하면

HDR과 성능의 관계

  1. 메모리 사용량 증가:
    • HDR 렌더링은 프레임 버퍼에 더 많은 메모리를 사용합니다.
    • 일반적으로 픽셀당 8비트 대신 16비트 또는 그 이상의 부동 소수점 형식을 사용합니다.
  2. GPU 연산 부하:
    • HDR 색상 처리는 더 복잡한 계산이 필요하므로 GPU에 추가 부하를 줍니다.
    • 특히 모바일 기기에서는 이 차이가 더 두드러집니다.
  3. 대역폭 사용량:
    • 렌더 텍스처 간 더 많은 데이터를 전송해야 하므로 메모리 대역폭 사용량이 증가합니다.
    • 모바일 기기는 대역폭이 제한적이어서 더 큰 영향을 받습니다.
  4. 후처리 효과와의 상호작용:
    • Bloom, 톤 매핑 등의 후처리 효과는 HDR이 활성화되었을 때 더 자연스럽고 효과적입니다.
    • 하지만 이런 효과들 역시 추가적인 성능 비용이 발생합니다.

현재 뱀서라이크에 픽셀 그래픽인 현재의 게임에서는 HDR을 비활성화해도 시각적인 품질에 큰 영향이 없을 가능성이 높다고 판단, 비활성화하여 성능 향상을 선택했다.

 

 

다음은 그림자 해상도 설정과 Addtional Lights 설정

필요가 없기에 끄기.

 

Lens Flare 효과는 이름에서 알 수 있듯이 현실적인 빛 효과를 내는 기능이다.

광원 효과라고 하는데, 실제 카메라에 태양이나 전구 등이 렌즈에 비칠 때 발생하는 현상 (산란, 반사, 굴절)을 표현해주는 기능이다.

역시 필요 없기에 비활성화.

 

 

 

다음은 애니메이션 처리인데, 뱀서라이크 특성상 다수의 캐릭터들이 동시다발적으로 등장하고 동시에 많은 애니메이션을 처리하기 때문에 성능을 많이 잡아먹는 것 같다.

또한 계속 플레이하면서 발견한 것은

 

Physics2D.Simulate도 역시 상당한 CPU 시간을 차지하고 있는 것을 확인했다.

애니메이션

문제와 물리 시뮬레이션 문제는 공통적으로 다수의 캐릭터들이 동시에 처리를 요구하고 있기 때문이라고 판단했고

 

시야 밖에 존재하는 적들이 많이 존재하는데, 시야 밖에 있는 오브젝트들이 리소스를 잡아먹는 것을 최대한 줄여야겠다고 생각했다.

우선 스프라이트 아틀라스를 좀 더 세분화했다.

게임 자체는 심플해서 그렇게 많이 세분화되지는 않았다.

아이템 카테고리별, VFX, 캐릭터 별로 세분화하였고

 

오브젝트 컬링(Object Culling)을 도입하기로 하였다.

 

오브젝트 컬링이란

오브젝트 컬링의 기본 개념

오브젝트 컬링은 카메라의 시야(Viewport)에 들어오지 않거나 보이지 않는 오브젝트의 렌더링이나 처리를 생략하는 기술입니다. 이를 통해 CPU와 GPU 자원을 절약하고 게임의 성능을 향상시킬 수 있습니다.

주요 컬링 유형

  1. 시야 컬링(Frustum Culling)
    • 카메라의 시야 영역(Frustum) 밖에 있는 오브젝트를 렌더링하지 않습니다.
    • Unity에서는 기본적으로 적용되는 컬링 방식입니다.
  2. 거리 기반 컬링(Distance-based Culling)
    • 카메라로부터 특정 거리 이상 떨어진 오브젝트를 렌더링하지 않거나 단순화합니다.
    • LOD(Level of Detail) 시스템과 연계하여 사용됩니다.
  3. 폐색 컬링(Occlusion Culling)
    • 다른 오브젝트에 의해 가려진(보이지 않는) 오브젝트를 렌더링하지 않습니다.
    • Unity에서는 별도의 베이킹 과정을 통해 구현합니다.
  4. 백페이스 컬링(Backface Culling)
    • 카메라를 향하지 않는 폴리곤의 뒷면을 렌더링하지 않습니다.
    • 대부분의 렌더링 파이프라인에서 기본적으로 적용됩니다.

로직 컬링(Logic Culling)

렌더링 외에도 오브젝트의 로직이나 애니메이션 처리를 컬링하여 CPU 사용량을 줄일 수 있습니다:

  1. 업데이트 컬링
    • 화면에 보이지 않거나 일정 거리 이상 떨어진 오브젝트의 Update() 함수 호출을 건너뜁니다.
  2. 애니메이션 컬링
    • Unity의 Animator 컴포넌트는 "Culling Mode" 속성을 통해 보이지 않는 오브젝트의 애니메이션 업데이트를 제어할 수 있습니다.
    • "Based On Renderers": 렌더러가 보이지 않을 때 애니메이션 업데이트 중지
    • "Always Animate": 항상 애니메이션 업데이트
    • "Cull Completely": 렌더러가 보이지 않을 때 완전히 애니메이션 처리 중지

쉽게 말해 플레이어가 보지 않고 있는 곳, 보이지 않는 곳은 렌더링하지 않는 것이다.

 

현재 개발 중인 게임은 플레이어를 중심으로 카메라가 고정된 상태로 움직이기 때문에

카메라 영역 기반 컬링을 적용하기로 하였다.

애니메이터 컬링도 적용해주고..

적이 스폰되는 영역과 컬링 영역을 나누었고,

플레이어는 능동적으로 움직이니까 컬링 영역에 있는 적이 갑자기 나타나거나 사라질 수 있는 현상이 있다.

(팝인/팝아웃)

을 방지하기 위해 버퍼 영역을 설정하여

노란색 영역(카메라)와 녹색 영역(버퍼 영역) 사이에 들어온 적들은 물리 로직과 애니메이션이 작동하게끔했다.

 

 

다음은 최근에 알게된 커다란 실수이다.

오브젝트 풀을 사용하면서 오브젝트 풀링이 적용된 오브젝트들을 하이어라키에서 보기 편하게 하기 위해

임의의 부모 컨테이너를 생성해 계층 구조를 갖게 했는데,

모든 풀링된 객체가 계층 구조에 포함되면 Transform 연산이 추가적으로 발생하고, 특히 많은 객체가 있을 때 게임 성능에 큰 영향을 줄 수가 있다고 한다.

 

하지만 개발할 때 불편한 건 감수하기 싫어서 useOptimizedHierarchy 플래그를 추가해

플래그 활성화 유무에 따라 계층 구조/비계층 구조를 선택 할 수 있게 했고,

실제 성능 체크를 위해 빌드를 할 때는 비계층 구조를 선택해 성능을 향상 시켰다.

 

2025-02-16 23:13:45

아르바이트를 하며 현재 개발 중인 게임을 출시하기 위해 집중을 하다보니,

블로그 관리가 너무 소홀해졌다.

 

그래서 현재 어느정도 개발이 진행된 게임의 최적화 및 리팩토링을 진행하면서

코드를 다시 되짚어보고,

최적화를 하기 위한 전략도 공부하며 다시 블로그를 업데이트하기로 한다.

 

 

현재 개발 중인 게임은 모바일 2d 뱀서라이크 장르의 게임이다.

많은 오브젝트들이 생성 및 파괴가 되는 장르이므로 오브젝트 풀링 등과 같은 구조는 어느정도 진행을 해 놓은 상태이다.

 

어느정도 개발이 진행된 게임이다보니 모바일에 설치하여 돌려봤는데..

이미지와 같이 굉장히 가벼운 이미지들로 이루어진 게임임에도 불구하고

인풋렉이 느껴지는 듯한 낮은 성능을 경험했다.

비교적 짧은 시간 안에 빠르게 작업을 진행하다보니 최적화를 염두하지 않고 코드를 짠 이유가 가장 크겠지만,

코드를 보기 전에 어떤 최적화 전략들이 있는지 알아보았다.


1.렌더링 최적화:

  • 스프라이트 아틀라스 사용: 여러 개의 작은 텍스처를 하나의 큰 텍스처로 합치면 드로우콜을 줄일 수 있습니다
  • 화면 밖 오브젝트의 렌더링 중지 (Culling): 카메라 밖의 오브젝트는 렌더링하지 않도록 처리
  • 동적 해상도 조정: 필요에 따라 게임 해상도를 자동으로 낮추는 기능 구현

   
2.물리 연산 최적화:

  • 물리 연산 간격 조절: 모든 프레임에서 물리 연산을 하지 않고, 일정 간격으로 수행
  • 간단한 충돌 체크 사용: 복잡한 폴리곤 대신 원형이나 사각형 콜라이더 활용
  • 멀리 있는 오브젝트의 물리 연산 빈도 줄이기

   
3.메모리 관리:

  • 에셋 로딩 최적화: 필요한 시점에 필요한 리소스만 로드
  • 가비지 컬렉션 최소화: 객체 재사용을 극대화하고, 런타임 중 새로운 객체 생성 최소화
  • 메모리 누수 체크: 주기적으로 메모리 프로파일링 수행


    4.코드 최적화 :
  • Update() 함수 최적화: 불필요한 연산 제거, 효율적인 알고리듬 사용
  • 코루틴 활용: 무거운 작업을 여러 프레임에 분산
  • 캐싱 활용: 자주 사용하는 컴포넌트나 값을 캐싱



    우선 스프라이트 아틀라스를 사용해보았다.


 

모든 이미지들을 단순히 한 아틀라스에 때려박는 것이 아닌,
이것도 최대한 세분화하는 것이 좋다.

  • 함께 표시되는 UI 요소들은 같은 아틀라스에 넣기
  • 씬이나 메뉴별로 다른 아틀라스 사용 고려
  • 자주 변경되지 않는 UI는 하나의 아틀라스로
  • 동적으로 로드되는 UI는 별도 아틀라스로 관리
  • 너무 큰 UI 이미지는 별도로 관리하는 것이 좋음
  • 해상도가 매우 높은 UI 요소들은 개별 관리 고려
  • 동적으로 자주 교체되는 UI 이미지는 별도 관리 고려
  • 메모리 관리를 위해 씬별로 아틀라스 분리 고려

 

 

다음은 아틀라스 설정이다.

특별히 중점을 둔 설정들은 Filter Mode와 Format이다.

 

모바일 게임에서는 성능을 고려할 때 권장하는 사항이 있다.

1.픽셀 아트 게임 : Point

2.일반 2D 게임 : Bilinear

3. Trilinear는 특별히 필요한 경우가 아니라면 피하는 것이 좋다.

 

Bilinear는 적당한 퀄리티와 성능의 균형을 제공하고, Trilinear는 Bilinear보다 더 부드러운 결과를 제공하지만, 약간의 성능 부하가 있다고 한다.

 

다음은 Format 설정이다.

보통은 ASTC나 ETC2로 설정한다고 한다.

ASTC로 설정한 이유는 :

  • 더 최신의 압축 방식
  • 다양한 블록 크기 옵션 제공 (4x4, 6x6, 8x8, 10x10, 12x12 등)
  • 블록 크기가 커질수록:
    • 압축률이 높아짐 (파일 크기 감소)
    • 화질은 낮아짐
    • 메모리 사용량 감소
    • 대부분의 최신 모바일 GPU에서 지원
    • 압축/해제 시 CPU 부하가 적음
    • 일반적으로 4x4나 6x6을 권장 (화질과 압축률의 좋은 균형)

ETC2 (Ericsson Texture Compression 2):

  • 안드로이드의 표준 텍스처 압축 포맷
  • 고정된 4x4 블록 크기
  • 더 오래된 기기들과의 호환성이 좋음
  • ASTC보다 약간 낮은 화질
  • 알파 채널이 있는 경우 ETC2_RGBA8 사용

현대 모바일 게임에서는 ASTC를 더 많이 사용하는 추세라고 한다. 특히 고화질 텍스처가 많은 게임이라면 ASTC가 더 좋은 선택이다.

 

다음 포스트부터는 코드 리뷰를하며 코드 최적화를 진행할 예정이다.

2024-10-18 14:11:53

 

그동안 생활비 부족으로 단기 아르바이트를 뛰느라 개발일지 작성이 소홀했다.

개발일지 작성은 소홀했지만 기능 개발은 조금씩 하면서 어느정도 조작 방식 시스템이 완성되었다.

 

선택된 유닛은 플레이어의 키보드 & 마우스 조작을 직접 받을 수 있게 되며,

선택되지 않은 유닛은 마지막으로 내려진 명령 혹은 자동 전투를 진행한다.

 

선택된 유닛은 스페이스바 키 : 홀드(이동을 하지 않으며 사거리 내 적 공격)

우클릭 이동 : 목표 지점 까지 이동하며 도달하면 이후 자동전투

 

드래그 박스 생성 방식은

IsTrigger가 활성화된 박스 콜라이더를 컴포넌트로 가진 게임오브젝트를 드래그 시작 지점과 끝나는 지점의 크기를 구해 활성화 & 비활성화 하는 방식으로 진행하였다.

박스 범위 안에 들어온 유닛들은 리스트에 등록되며 이후 조작을 받을 수 있게 하였다.

 

기존 정의된 상태에서 우클릭 이동 상태, 홀드 상태를 추가하여 조작을 받을 시 자동전투였던 상태에서 조작을 받은 상태로
전환 될 수 있게 하였다.

 

오늘 다른 팀원들과 병합 이후 클릭 위치 시각화 등 나머지 유지보수를 진행할 예정이다.