[방치형게임 만들기] 튜토리얼 시스템
📕학습 개요
오늘는 유니티(Unity)로 튜토리얼 시스템을 기획하고 구현하는 과정에서 발생한 여러 문제와 해결책을 학습했다. 특히 데이터 관리의 유연성과 이벤트 처리의 중요성에 대해 깊이 이해할 수 있었다.
📖학습 내용
1. 튜토리얼 데이터 관리와 다형성(Polymorphism) 문제
- 문제점:
JsonUtility를 사용해 JSON 데이터를 역직렬화할 때,List<BaseClass>형태의 리스트는 자식 클래스(DialogueStep,ActionStep)의 고유 필드를 제대로 불러오지 못한다. 시스템은 모든 요소를BaseClass로만 인식하여 데이터가 유실된다. - 해결책: ‘상속 대신 포함(Composition over Inheritance)’ 패턴을 적용했다.
- 베이스 클래스가 자식 클래스를 상속하는 대신, 각 타입에 맞는 데이터를 담는
Payload클래스를 멤버 변수로 포함하도록 구조를 변경했다. TutorialStepData클래스 내에DialogueStepPayload와ActionStepPayload를 두고,typeenum 값에 따라 필요한Payload를 선택적으로 사용했다.- 이 방법은
JsonUtility와 완벽하게 호환되며 데이터 구조가 명확해진다.
- 베이스 클래스가 자식 클래스를 상속하는 대신, 각 타입에 맞는 데이터를 담는
[Serializable]
public class DialogueStepPayload // Dialogue 전용 데이터를 담는 클래스
{
public string npcName;
public string dialogueText;
public int leftSpriteID;
public int rightSpriteID;
}
[Serializable]
public class ActionStepPayload // Action 전용 데이터를 담는 클래스
{
public int targetButtonKey;
public string actionLog;
public int indicatorPosition;
}
[Serializable]
public class TutorialStepData // 메인 데이터 (상속 구조 제거)
{
public int key;
public TutorialStepType type;
public float startDelay;
// 각 타입에 맞는 데이터. JSON 상에서 하나만 채워짐.
public DialogueStepPayload dialoguePayload;
public ActionStepPayload actionPayload;
}
2. 동적 데이터 참조와 로딩
- 문제점:
ArgumentException: Could not cast from System.Int64 to Data.TutorialStepData.오류가 발생했다. - 원인: 튜토리얼 흐름을 정의하는 데이터에서 실제 스텝 데이터 객체(
TutorialStepData)의 리스트 대신, 스텝의 ID(key)값 리스트를 참조하고 있었기 때문이다. - 해결책: 데이터 로딩 파이프라인을 2단계로 구성했다.
- 모든 개별 스텝 데이터(
TutorialStepData)를Dictionary<int, TutorialStepData>형태로 먼저 로드한다. - 튜토리얼 흐름 데이터(
TutorialData)에서는List<int> stepKeys를 참조하도록 클래스 구조를 변경한다. - 튜토리얼 실행 시,
stepKeys리스트를 순회하며 Dictionary에서 실제 스텝 데이터를 꺼내와 사용한다. 이로써 데이터의 정의와 흐름을 완벽히 분리하여 유연성을 확보했다.
- 모든 개별 스텝 데이터(
3. 유연한 UI 이벤트 처리
- 요구사항:
Button과Toggle컴포넌트 모두의 클릭 이벤트를 동일한 방식으로 처리하고 싶었다. -
해결책:
TryGetComponent를 사용하여GameObject로부터Button또는Toggle컴포넌트가 있는지 확인 후 분기 처리했다.Button은onClick이벤트를 사용.Toggle은onValueChanged이벤트를 사용하되,isOn == true일 때만 동작하도록 람다식으로 감싸UnityAction과 호환되도록 만들었다.
4. ‘단 한 번만 실행되는’ 이벤트 리스너 구현
- 문제점: 이벤트 리스너를 추가한 뒤, 완료 시점에 수동으로
RemoveListener를 호출해야 하는 번거로움과 실수의 여지가 있었다. 특히 람다식으로 추가한 리스너는 제거가 까다롭다. -
해결책: ‘래퍼(Wrapper) 액션’ 패턴을 사용했다.
- 리스너에 등록할
UnityAction을 새로 정의한다. - 이 래퍼 액션은 1. 원래 실행하려던 로직을 호출하고, 2. 자기 자신을 이벤트에서 제거(
RemoveListener)하는 두 가지 역할을 모두 수행한다. -
이로써
RemoveListener를 외부에서 신경 쓸 필요 없이, 리스너가 스스로를 정리하는 깔끔한 구조를 만들었다.public void AddListener(UIButtonType type, UnityAction action) { // 1. 딕셔너리에서 GameObject를 가져오기 if (!tutorialButtons.TryGetValue(type, out GameObject uiObject)) { return; } // 2. Button 컴포넌트가 있는지 확인하고 리스너를 추가 if (uiObject.TryGetComponent<Button>(out Button button)) { UnityAction wrapperAction = null; wrapperAction = () => { action?.Invoke(); button.onClick.RemoveListener(wrapperAction); }; button.onClick.AddListener(wrapperAction); return; } // 3. Button이 없다면, Toggle 컴포넌트가 있는지 확인하고 리스너를 추가 if (uiObject.TryGetComponent<Toggle>(out Toggle toggle)) { // Toggle은 onValueChanged 이벤트가 bool 값을 전달하므로, 람다식으로 감싸줌. // 여기서는 토글이 켜질 때(isOn == true)만 액션을 실행하도록 처리 UnityAction<bool> wrapperToggleAction = null; wrapperToggleAction = (isOn) => { if (isOn) { action.Invoke(); toggle.onValueChanged.RemoveListener(wrapperToggleAction); } }; toggle.onValueChanged.AddListener(wrapperToggleAction); return; // 토글을 찾았으므로 함수 종료 } Debug.LogWarning($"[TutorialManager] No Button or Toggle component found on object for type: {type}"); }
- 리스너에 등록할
댓글남기기