우선 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' 상태 등의 기법을 통해 모바일 환경에서 원할하게 작동할 수 있도록 노력하였다.
캔버스 구조는 이렇게 구성하였다. 검은 화면 밑 텍스트는 설정한 타이밍에 따라 페이드인/아웃과 타이핑하는 듯한 텍스트 효과를 주었다. 그리고 아래로 긴 스크롤 이미지 연출을 위해 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과 코루틴, 적절한 함수를 활용하면 애니메이션이나 화려한 그래픽 리소스가 없어도 괜찮은 느낌을 줄 수 있어 앞으로도 잘 활용할 것 같다.
여태까지 적 캐릭터들의 스폰 방식은 플레이어를 중심으로 하여 정해진 범위 안에서 랜덤한 위치에 스폰되어 플레이어를 추적했다.
맵은 무한히 동적으로 커지는 방식이어서 플레이어는 적을 피해 잘 도망다닐 수 있었고, 이로 인해 난이도가 너무 쉬워진 느낌을 받았다.
그리고 모바일 환경에서 동적으로 무한히 커지는 맵 방식은 최적화에도 그리 좋지 않았다.
그렇게 앞으로의 레벨 디자인과 성능을 위해 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);
}
}