diff --git a/Assets/_DDD/_Scripts/GameUi/UiGuideline b/Assets/_DDD/_Scripts/GameUi/UiGuideline new file mode 100644 index 000000000..f6e909b2b --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/UiGuideline @@ -0,0 +1,545 @@ +# 새로운 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를 효율적으로 개발할 수 있습니다. \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/UiGuideline.meta b/Assets/_DDD/_Scripts/GameUi/UiGuideline.meta new file mode 100644 index 000000000..dd529aea6 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/UiGuideline.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 31ff6b5920be49feb652a1b614d62c15 +timeCreated: 1755681370 \ No newline at end of file