# 새로운 UI 개발을 위한 단계별 가이드라인 Unity 프로젝트에서 RestaurantManagementUi를 기반으로 새로운 UI를 만들 때 따라야 할 구체적인 단계별 가이드라인을 제공합니다. ## 개발 순서 및 클래스 생성 가이드 ### 1단계: 요구사항 분석 및 설계 #### 먼저 결정해야 할 사항들 - **UI 타입**: 일반 UI인가? 팝업 UI인가? - **입력 처리**: 키보드/게임패드 입력이 필요한가? - **데이터 복잡도**: 단순한 표시용인가? 복잡한 상태 관리가 필요한가? - **재사용성**: 다른 곳에서도 사용될 가능성이 있는가? #### 예시: ShopUi를 만든다고 가정 ``` 요구사항: - 상품 목록 표시 (카테고리별 필터링) - 상품 구매 기능 - 소지금 표시 - 키보드 입력 지원 - 팝업 형태로 동작 ``` ### 2단계: Service 클래스 생성 (비즈니스 로직) **가장 먼저 Service부터 시작하는 이유:** - 비즈니스 로직이 명확해야 UI 설계가 가능 - 테스트 가능한 코드 작성 - ViewModel에서 사용할 데이터와 기능 정의 #### ShopService.cs ```csharp // Assets/_DDD/_Scripts/GameUi/New/Services/ShopService.cs namespace DDD.MVVM { public class ShopService : IUiService { private ShopData _shopData; private PlayerInventoryData _playerData; public void Initialize() { _shopData = DataManager.Instance.GetDataSo(); _playerData = PlayerState.Instance.InventoryData; } public void Cleanup() { } public void UpdateUiState() { } /// /// 카테고리별 상품 목록 가져오기 /// public IEnumerable GetItemsByCategory(ShopCategoryType category) { return _shopData.Items .Where(item => MatchesCategory(item, category)) .Select(item => new ShopItemViewModel(item)) .ToList(); } /// /// 상품 구매 가능 여부 확인 /// public bool CanPurchaseItem(string itemId, int quantity = 1) { var item = _shopData.GetItemById(itemId); return _playerData.Money >= (item.Price * quantity); } /// /// 상품 구매 처리 /// public bool PurchaseItem(string itemId, int quantity = 1) { if (!CanPurchaseItem(itemId, quantity)) return false; var item = _shopData.GetItemById(itemId); var totalCost = item.Price * quantity; _playerData.Money -= totalCost; _playerData.AddItem(itemId, quantity); return true; } private bool MatchesCategory(ShopItemData item, ShopCategoryType category) { // 카테고리 매칭 로직 return category switch { ShopCategoryType.Food => item.Type == ItemType.Food, ShopCategoryType.Equipment => item.Type == ItemType.Equipment, _ => true }; } } } ``` ### 3단계: ViewModel 클래스 생성 (상태 관리) Service가 준비되었으니 이를 활용하는 ViewModel을 생성합니다. #### ShopViewModel.cs ```csharp // Assets/_DDD/_Scripts/GameUi/New/ViewModels/ShopViewModel.cs namespace DDD.MVVM { public class ShopViewModel : SimpleViewModel, IEventHandler { [Header("Services")] [SerializeField] private ShopService _shopService; // Private fields for properties private ShopCategoryType _currentCategory = ShopCategoryType.All; private List _visibleItems = new(); private ShopItemViewModel _selectedItem; private int _playerMoney; /// /// 현재 선택된 카테고리 /// public ShopCategoryType CurrentCategory { get => _currentCategory; set { if (SetField(ref _currentCategory, value)) { UpdateVisibleItems(); OnPropertyChanged(nameof(CategoryDisplayText)); } } } /// /// 보이는 상품 목록 /// public List VisibleItems { get => _visibleItems; private set => SetField(ref _visibleItems, value); } /// /// 현재 선택된 상품 /// public ShopItemViewModel SelectedItem { get => _selectedItem; set => SetField(ref _selectedItem, value); } /// /// 플레이어 소지금 /// public int PlayerMoney { get => _playerMoney; set => SetField(ref _playerMoney, value); } // 계산된 속성들 public string CategoryDisplayText => CurrentCategory switch { ShopCategoryType.Food => "음식", ShopCategoryType.Equipment => "장비", _ => "전체" }; public string MoneyDisplayText => $"{PlayerMoney:N0} G"; public bool HasVisibleItems => VisibleItems.Any(); public bool CanPurchaseSelected => SelectedItem != null && _shopService.CanPurchaseItem(SelectedItem.Id); protected override void Awake() { base.Awake(); if (_shopService == null) _shopService = new ShopService(); } public override void Initialize() { base.Initialize(); _shopService.Initialize(); LoadShopData(); RegisterEvents(); } public override void Cleanup() { base.Cleanup(); UnregisterEvents(); _shopService?.Cleanup(); } private void RegisterEvents() { EventBus.Register(this); } private void UnregisterEvents() { EventBus.Unregister(this); } private void LoadShopData() { PlayerMoney = PlayerState.Instance.InventoryData.Money; UpdateVisibleItems(); } private void UpdateVisibleItems() { BeginUpdate(); var items = _shopService.GetItemsByCategory(CurrentCategory); VisibleItems = items.ToList(); OnPropertyChanged(nameof(HasVisibleItems)); OnPropertyChanged(nameof(CanPurchaseSelected)); EndUpdate(); } /// /// 카테고리 변경 /// public void SetCategory(ShopCategoryType category) { CurrentCategory = category; } /// /// 상품 선택 /// public void SelectItem(ShopItemViewModel item) { SelectedItem = item; OnPropertyChanged(nameof(CanPurchaseSelected)); } /// /// 상품 구매 /// public bool PurchaseSelectedItem(int quantity = 1) { if (SelectedItem == null || !CanPurchaseSelected) return false; var success = _shopService.PurchaseItem(SelectedItem.Id, quantity); if (success) { // 구매 성공 시 UI 업데이트 LoadShopData(); } return success; } // 이벤트 핸들러 public void Invoke(MoneyChangedEvent evt) { PlayerMoney = evt.NewAmount; OnPropertyChanged(nameof(MoneyDisplayText)); OnPropertyChanged(nameof(CanPurchaseSelected)); } } } ``` ### 4단계: ItemViewModel 클래스 생성 (필요시) 복잡한 아이템이 있다면 별도의 ItemViewModel을 생성합니다. #### ShopItemViewModel.cs ```csharp // Assets/_DDD/_Scripts/GameUi/New/ViewModels/ShopItemViewModel.cs namespace DDD.MVVM { public class ShopItemViewModel : SimpleViewModel { private ShopItemData _itemData; private bool _isSelected; public string Id => _itemData?.Id ?? ""; public string Name => _itemData?.Name ?? ""; public string Description => _itemData?.Description ?? ""; public int Price => _itemData?.Price ?? 0; public Sprite Icon => _itemData?.Icon; public bool IsSelected { get => _isSelected; set => SetField(ref _isSelected, value); } public string PriceDisplayText => $"{Price:N0} G"; public ShopItemViewModel(ShopItemData itemData) { _itemData = itemData; } } } ``` ### 5단계: View 클래스 생성 마지막으로 UI View를 생성합니다. RestaurantManagementUi를 참고하여 구조를 만듭니다. #### ShopUi.cs ```csharp // Assets/_DDD/_Scripts/GameUi/PopupUi/ShopUi/ShopUi.cs namespace DDD { /// /// 상점 UI - RestaurantManagementUi 구조를 참고하여 구현 /// [RequireComponent(typeof(ShopViewModel))] public class ShopUi : IntegratedBasePopupUi { [Header("UI References")] // Attribute 기반 자동 바인딩 [SerializeField, BindTo(nameof(ShopViewModel.CategoryDisplayText))] private Text _categoryLabel; [SerializeField, BindTo(nameof(ShopViewModel.MoneyDisplayText))] private Text _moneyLabel; [SerializeField, BindTo(nameof(ShopViewModel.HasVisibleItems), typeof(InvertBoolConverter))] private GameObject _emptyMessage; // 수동 처리가 필요한 UI 요소들 [SerializeField] private Transform _itemSlotParent; [SerializeField] private GameObject _itemSlotPrefab; [SerializeField] private Button[] _categoryButtons; [SerializeField] private Button _purchaseButton; public override InputActionMaps InputActionMaps => InputActionMaps.Shop; protected override GameObject GetInitialSelected() { // 첫 번째 상품 슬롯 또는 카테고리 버튼 반환 var firstSlot = _itemSlotParent.childCount > 0 ? _itemSlotParent.GetChild(0).gameObject : null; return firstSlot ?? (_categoryButtons.Length > 0 ? _categoryButtons[0].gameObject : null); } protected override void SetupBindings() { // Attribute로 처리하기 어려운 복잡한 바인딩들은 여기서 수동 설정 } protected override void HandleCustomPropertyChanged(string propertyName) { switch (propertyName) { case nameof(ShopViewModel.VisibleItems): UpdateItemSlots(); break; case nameof(ShopViewModel.CurrentCategory): UpdateCategoryButtons(); break; case nameof(ShopViewModel.CanPurchaseSelected): _purchaseButton.interactable = ViewModel.CanPurchaseSelected; break; } } private void UpdateItemSlots() { // 기존 슬롯 정리 foreach (Transform child in _itemSlotParent) { Destroy(child.gameObject); } // 새 슬롯 생성 if (ViewModel?.VisibleItems != null) { foreach (var item in ViewModel.VisibleItems) { var slotGO = Instantiate(_itemSlotPrefab, _itemSlotParent); var slotComponent = slotGO.GetComponent(); slotComponent.Initialize(item, OnItemClicked); } } } private void UpdateCategoryButtons() { if (_categoryButtons == null || ViewModel == null) return; for (int i = 0; i < _categoryButtons.Length; i++) { var isSelected = (int)ViewModel.CurrentCategory == i; _categoryButtons[i].interactable = !isSelected; } } // UI 이벤트 핸들러들 public void OnCategoryButtonClicked(int categoryIndex) { ViewModel?.SetCategory((ShopCategoryType)categoryIndex); } public void OnPurchaseButtonClicked() { if (ViewModel?.PurchaseSelectedItem() == true) { // 구매 성공 피드백 ShowPurchaseSuccessEffect(); } } private void OnItemClicked(ShopItemViewModel item) { ViewModel?.SelectItem(item); } private void ShowPurchaseSuccessEffect() { // 구매 성공 시 시각적 피드백 } // 입력 처리 protected override bool OnInputPerformed(ShopUiActions actionEnum, UnityEngine.InputSystem.InputAction.CallbackContext context) { var isHandled = base.OnInputPerformed(actionEnum, context); if (isHandled && ViewModel != null) { switch (actionEnum) { case ShopUiActions.Purchase: OnPurchaseButtonClicked(); break; case ShopUiActions.Cancel: Close(); break; } } return isHandled; } } } ``` ### 6단계: 입력 액션 정의 (필요시) 새로운 UI에 특별한 입력이 필요하다면 입력 액션을 정의합니다. #### ShopUiActions.cs ```csharp // Assets/_DDD/_Scripts/Input/ShopUiActions.cs namespace DDD { [System.Flags] public enum ShopUiActions { None = 0, Navigate = 1 << 0, Submit = 1 << 1, Cancel = 1 << 2, Purchase = 1 << 3, PreviousCategory = 1 << 4, NextCategory = 1 << 5, } } ``` ## 개발 체크리스트 ### Service 개발 체크리스트 - [ ] IService 인터페이스 구현 - [ ] 데이터 접근 로직 캡슐화 - [ ] 비즈니스 로직 구현 - [ ] 에러 처리 및 검증 로직 - [ ] 단위 테스트 가능한 구조 ### ViewModel 개발 체크리스트 - [ ] SimpleViewModel 상속 - [ ] 모든 상태를 속성으로 정의 - [ ] SetField를 통한 PropertyChanged 알림 - [ ] 계산된 속성 구현 - [ ] Service 의존성 주입 - [ ] 이벤트 등록/해제 - [ ] 생명주기 메서드 구현 ### View 개발 체크리스트 - [ ] 적절한 Base 클래스 상속 선택 - 일반 UI: IntegratedBaseUi - 팝업 UI: IntegratedBasePopupUi - 입력 필요 팝업: IntegratedBasePopupUi - [ ] BindTo Attribute 적용 - [ ] GetInitialSelected() 구현 - [ ] SetupBindings() 구현 (필요시) - [ ] HandleCustomPropertyChanged() 구현 - [ ] UI 이벤트 핸들러 ViewModel로 연결 ## 폴더 구조 권장사항 ``` Assets/_DDD/_Scripts/GameUi/PopupUi/ShopUi/ ├── ShopUi.cs # View 클래스 ├── ShopItemSlot.cs # 하위 UI 컴포넌트 └── ShopUiData.cs # UI 설정 데이터 (필요시) Assets/_DDD/_Scripts/GameUi/New/ ├── Services/ │ └── ShopService.cs # Service 클래스 ├── ViewModels/ │ ├── ShopViewModel.cs # 메인 ViewModel │ └── ShopItemViewModel.cs # 아이템 ViewModel └── Converters/ └── PriceConverter.cs # 커스텀 컨버터 (필요시) ``` ## 개발 팁 ### 1. 기존 코드 활용 - RestaurantManagementUi의 패턴을 최대한 재활용 - InventoryView의 필터링/정렬 로직 참고 - 기존 Service 클래스들의 구조 패턴 따라하기 ### 2. 점진적 개발 1. 먼저 기본 기능만 구현 (Service → ViewModel → View) 2. UI 바인딩은 단순한 것부터 시작 3. 복잡한 기능은 나중에 추가 ### 3. 테스트 우선 - Service 로직은 반드시 단위 테스트 - ViewModel은 Mock Service로 테스트 - View는 수동 테스트로 검증 이 가이드라인을 따라하면 일관성 있고 유지보수가 쉬운 UI를 효율적으로 개발할 수 있습니다.