diff --git a/Assets/_DDD/_Scripts/GameUi/New.meta b/Assets/_DDD/_Scripts/GameUi/New.meta new file mode 100644 index 000000000..0034041f1 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b710f3e6683e1e649a6d44dc476b5083 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_DDD/_Scripts/GameUi/New/Converters.meta b/Assets/_DDD/_Scripts/GameUi/New/Converters.meta new file mode 100644 index 000000000..1b0a4a586 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Converters.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 75317739247907e4099f44de6f643a56 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_DDD/_Scripts/GameUi/New/Converters/CommonConverters.cs b/Assets/_DDD/_Scripts/GameUi/New/Converters/CommonConverters.cs new file mode 100644 index 000000000..d15ab1849 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Converters/CommonConverters.cs @@ -0,0 +1,223 @@ +using System.Linq; +using UnityEngine; + +namespace DDD.MVVM +{ + /// + /// 불린 값을 반전시키는 컨버터 + /// 예: true → false, false → true + /// + public class InvertBoolConverter : IValueConverter + { + public object Convert(object value) + { + return value is bool boolValue ? !boolValue : false; + } + + public object ConvertBack(object value) + { + return Convert(value); // 반전은 양방향 동일 + } + } + + /// + /// 아이템 개수를 텍스트로 변환하는 컨버터 + /// 예: 5 → "아이템 수: 5" + /// + public class ItemCountConverter : IValueConverter + { + public object Convert(object value) + { + if (value is int count) + return $"아이템 수: {count}"; + return "아이템 수: 0"; + } + + public object ConvertBack(object value) + { + if (value is string str && str.StartsWith("아이템 수: ")) + { + var countStr = str.Substring("아이템 수: ".Length); + return int.TryParse(countStr, out var count) ? count : 0; + } + return 0; + } + } + + /// + /// null 또는 빈 값을 불린으로 변환하는 컨버터 + /// 예: null → false, "" → false, "text" → true + /// + public class IsNullOrEmptyConverter : IValueConverter + { + public object Convert(object value) + { + if (value == null) return true; + if (value is string str) return string.IsNullOrEmpty(str); + return false; + } + + public object ConvertBack(object value) + { + return value is bool boolValue && !boolValue ? "" : null; + } + } + + /// + /// 숫자를 백분율 텍스트로 변환하는 컨버터 + /// 예: 0.75f → "75%" + /// + public class PercentageConverter : IValueConverter + { + public object Convert(object value) + { + if (value is float floatValue) + return $"{(floatValue * 100):F0}%"; + if (value is double doubleValue) + return $"{(doubleValue * 100):F0}%"; + return "0%"; + } + + public object ConvertBack(object value) + { + if (value is string str && str.EndsWith("%")) + { + var percentStr = str.Substring(0, str.Length - 1); + if (float.TryParse(percentStr, out var percent)) + return percent / 100f; + } + return 0f; + } + } + + /// + /// 열거형을 문자열로 변환하는 컨버터 + /// + public class EnumToStringConverter : IValueConverter + { + public object Convert(object value) + { + return value?.ToString() ?? string.Empty; + } + + public object ConvertBack(object value) + { + // 역변환은 타입 정보가 필요하므로 기본 구현만 제공 + return value; + } + } + + /// + /// 컬렉션의 개수를 확인하는 컨버터 + /// 예: List(5개) → true, List(0개) → false + /// + public class CollectionHasItemsConverter : IValueConverter + { + public object Convert(object value) + { + if (value is System.Collections.ICollection collection) + return collection.Count > 0; + if (value is System.Collections.IEnumerable enumerable) + return enumerable.Cast().Any(); + return false; + } + + public object ConvertBack(object value) + { + return value; // 역변환 불가 + } + } + + /// + /// 색상 투명도 조절 컨버터 + /// 불린 값에 따라 알파값을 조절 + /// + public class AlphaConverter : IValueConverter + { + public float EnabledAlpha { get; set; } = 1.0f; + public float DisabledAlpha { get; set; } = 0.5f; + + public object Convert(object value) + { + if (value is bool boolValue) + return boolValue ? EnabledAlpha : DisabledAlpha; + return EnabledAlpha; + } + + public object ConvertBack(object value) + { + if (value is float alpha) + return Mathf.Approximately(alpha, EnabledAlpha); + return true; + } + } + + /// + /// InventoryCategoryType을 한국어로 변환하는 컨버터 + /// + public class InventoryCategoryConverter : IValueConverter + { + public object Convert(object value) + { + if (value is InventoryCategoryType category) + { + return category switch + { + InventoryCategoryType.Food => "음식", + InventoryCategoryType.Drink => "음료", + InventoryCategoryType.Ingredient => "재료", + InventoryCategoryType.Cookware => "조리도구", + InventoryCategoryType.Special => "특수", + _ => "전체" + }; + } + return "전체"; + } + + public object ConvertBack(object value) + { + if (value is string str) + { + return str switch + { + "음식" => InventoryCategoryType.Food, + "음료" => InventoryCategoryType.Drink, + "재료" => InventoryCategoryType.Ingredient, + "조리도구" => InventoryCategoryType.Cookware, + "특수" => InventoryCategoryType.Special, + _ => InventoryCategoryType.Food + }; + } + return InventoryCategoryType.Food; + } + } + + /// + /// 가격을 통화 형식으로 변환하는 컨버터 + /// 예: 1000 → "1,000원" + /// + public class CurrencyConverter : IValueConverter + { + public string CurrencySymbol { get; set; } = "원"; + + public object Convert(object value) + { + if (value is int intValue) + return $"{intValue:N0}{CurrencySymbol}"; + if (value is float floatValue) + return $"{floatValue:N0}{CurrencySymbol}"; + return $"0{CurrencySymbol}"; + } + + public object ConvertBack(object value) + { + if (value is string str && str.EndsWith(CurrencySymbol)) + { + var numberStr = str.Substring(0, str.Length - CurrencySymbol.Length).Replace(",", ""); + if (int.TryParse(numberStr, out var number)) + return number; + } + return 0; + } + } +} \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Converters/CommonConverters.cs.meta b/Assets/_DDD/_Scripts/GameUi/New/Converters/CommonConverters.cs.meta new file mode 100644 index 000000000..ff3ec6bd8 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Converters/CommonConverters.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c7031b52d02cf3479d48a4cab3ca66e \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Converters/IValueConverter.cs b/Assets/_DDD/_Scripts/GameUi/New/Converters/IValueConverter.cs new file mode 100644 index 000000000..16bb141ff --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Converters/IValueConverter.cs @@ -0,0 +1,39 @@ +namespace DDD.MVVM +{ + /// + /// 값 변환기 인터페이스 + /// ViewModel의 데이터를 View에서 표시하기 적합한 형태로 변환 + /// + public interface IValueConverter + { + /// + /// ViewModel 값을 View 표시용으로 변환 + /// + /// 변환할 값 + /// 변환된 값 + object Convert(object value); + + /// + /// View 값을 ViewModel용으로 역변환 (선택적 구현) + /// + /// 역변환할 값 + /// 역변환된 값 + object ConvertBack(object value) + { + return value; // 기본 구현: 그대로 반환 + } + } + + /// + /// 간단한 값 변환기 인터페이스 (단방향 전용) + /// + public interface ISimpleConverter + { + /// + /// ViewModel 값을 View 표시용으로 변환 + /// + /// 변환할 값 + /// 변환된 값 + object Convert(object value); + } +} \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Converters/IValueConverter.cs.meta b/Assets/_DDD/_Scripts/GameUi/New/Converters/IValueConverter.cs.meta new file mode 100644 index 000000000..56100971e --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Converters/IValueConverter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a0229b137966b684f99c93cb7a488e48 \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/MIGRATION_GUIDE.md b/Assets/_DDD/_Scripts/GameUi/New/MIGRATION_GUIDE.md new file mode 100644 index 000000000..5c57932cb --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/MIGRATION_GUIDE.md @@ -0,0 +1,321 @@ +# 기존 UI 시스템을 MVVM으로 마이그레이션 가이드 + +Unity 프로젝트에서 기존 UI 시스템(BaseUi, BasePopupUi, PopupUi)을 MVVM 패턴으로 점진적으로 전환하는 방법을 설명합니다. + +## 호환성 보장 + +### 1. 기존 UI 시스템은 그대로 유지됩니다 +- `BaseUi`, `BasePopupUi`, `PopupUi` 클래스들은 변경되지 않음 +- 기존 UI들은 계속해서 정상 동작 +- `UiManager`와 `PopupUiState`도 기존 방식 그대로 지원 + +### 2. 새로운 Integrated 클래스들 사용 +- `IntegratedBaseUi` : BaseUi + MVVM 기능 통합 +- `IntegratedBasePopupUi` : BasePopupUi + MVVM 기능 통합 +- `IntegratedPopupUi` : PopupUi + MVVM 기능 통합 + +## 마이그레이션 전략 + +### 단계 1: 점진적 전환 계획 수립 + +1. **우선순위 결정** + ``` + 높음: 복잡한 상태 관리가 필요한 UI (InventoryView, ShopView 등) + 중간: 중간 규모의 팝업 UI + 낮음: 간단한 확인/알림 다이얼로그 + ``` + +2. **전환 범위 설정** + - 한 번에 하나의 UI씩 전환 + - 관련된 ViewModel과 Service 함께 구현 + - 철저한 테스트 후 다음 UI 진행 + +### 단계 2: ViewModel 및 Service 구현 + +#### 기존 View 분석 +```csharp +// 기존 InventoryView +public class InventoryView : MonoBehaviour +{ + private InventoryCategoryType _currentCategory; + private List _items; + + public void UpdateCategoryView(InventoryCategoryType category) + { + _currentCategory = category; + // 복잡한 UI 업데이트 로직 + } +} +``` + +#### ViewModel로 분리 +```csharp +// 새로운 InventoryViewModel +public class InventoryViewModel : SimpleViewModel +{ + private InventoryCategoryType _currentCategory; + private List _visibleItems; + + public InventoryCategoryType CurrentCategory + { + get => _currentCategory; + set => SetField(ref _currentCategory, value); + } + + public void SetCategory(InventoryCategoryType category) + { + CurrentCategory = category; + UpdateVisibleItems(); // 비즈니스 로직 + } +} +``` + +#### Service 계층 분리 +```csharp +// 비즈니스 로직을 Service로 분리 +public class InventoryService : IUiService +{ + public IEnumerable FilterItems(InventoryCategoryType category) + { + // 복잡한 필터링 로직 + } +} +``` + +### 단계 3: View를 MVVM으로 전환 + +#### 기존 View 코드 +```csharp +public class InventoryView : MonoBehaviour, IEventHandler +{ + [SerializeField] private Transform _slotParent; + [SerializeField] private Text _categoryLabel; + + // 많은 상태 변수들과 복잡한 로직들... +} +``` + +#### MVVM View로 전환 +```csharp +public class MvvmInventoryView : MvvmBasePopupUi +{ + [SerializeField] private Transform _slotParent; + [SerializeField] private Text _categoryLabel; + + protected override void SetupBindings() + { + BindText(_categoryLabel, nameof(InventoryViewModel.CategoryDisplayText)); + // 간단한 바인딩 설정만 + } + + public void OnCategoryButtonClicked(int categoryIndex) + { + ViewModel.SetCategory((InventoryCategoryType)categoryIndex); + } +} +``` + +### 단계 4: 마이그레이션 체크리스트 + +#### ViewModel 체크리스트 +- [ ] SimpleViewModel 상속 +- [ ] 모든 상태를 속성으로 변환 +- [ ] SetField 사용한 PropertyChanged 알림 +- [ ] 계산된 속성 구현 +- [ ] 비즈니스 로직을 Service로 위임 + +#### View 체크리스트 +- [ ] 적절한 MVVM Base 클래스 상속 +- [ ] SetupBindings() 구현 +- [ ] UI 이벤트를 ViewModel 메서드 호출로 변경 +- [ ] 직접적인 UI 업데이트 코드 제거 +- [ ] HandleCustomPropertyChanged에서 복잡한 UI 처리 + +#### Service 체크리스트 +- [ ] IService 인터페이스 구현 +- [ ] 복잡한 비즈니스 로직 포함 +- [ ] 데이터 접근 로직 캡슐화 +- [ ] 테스트 가능한 구조 + +## 구체적인 마이그레이션 예시 + +### 예시 1: 간단한 팝업 UI + +#### Before (기존) +```csharp +public class SimpleDialogUi : BasePopupUi +{ + [SerializeField] private Text _messageText; + [SerializeField] private Button _confirmButton; + + public void ShowMessage(string message) + { + _messageText.text = message; + _confirmButton.onClick.AddListener(Close); + } +} +``` + +#### After (MVVM) +```csharp +// ViewModel +public class DialogViewModel : SimpleViewModel +{ + private string _message; + + public string Message + { + get => _message; + set => SetField(ref _message, value); + } +} + +// View +public class MvvmSimpleDialogUi : MvvmBasePopupUi +{ + [SerializeField] private Text _messageText; + [SerializeField] private Button _confirmButton; + + protected override void SetupBindings() + { + BindText(_messageText, nameof(DialogViewModel.Message)); + } + + protected override void Awake() + { + base.Awake(); + _confirmButton.onClick.AddListener(() => Close()); + } +} +``` + +### 예시 2: 복잡한 목록 UI + +#### Before (기존 InventoryView) +```csharp +public class InventoryView : MonoBehaviour +{ + private InventoryCategoryType _currentCategory; + private Dictionary _slotLookup = new(); + + public void UpdateCategoryView(InventoryCategoryType category) + { + _currentCategory = category; + + // 복잡한 필터링 로직 + foreach (var slot in _slotLookup.Values) + { + bool shouldShow = MatchesCategory(slot.Model, category); + slot.SetActive(shouldShow); + } + + // 정렬 로직 + // UI 업데이트 로직 + } + + private bool MatchesCategory(ItemViewModel model, InventoryCategoryType category) + { + // 복잡한 카테고리 매칭 로직 + } +} +``` + +#### After (MVVM) +```csharp +// ViewModel (상태 관리) +public class InventoryViewModel : SimpleViewModel +{ + private InventoryCategoryType _currentCategory; + private List _visibleItems; + + public InventoryCategoryType CurrentCategory + { + get => _currentCategory; + set => SetField(ref _currentCategory, value); + } + + public List VisibleItems + { + get => _visibleItems; + private set => SetField(ref _visibleItems, value); + } + + public string CategoryDisplayText => GetCategoryDisplayText(); + + public void SetCategory(InventoryCategoryType category) + { + CurrentCategory = category; + UpdateVisibleItems(); + OnPropertyChanged(nameof(CategoryDisplayText)); + } + + private void UpdateVisibleItems() + { + var filtered = _inventoryService.FilterItems(CurrentCategory, CurrentSortType); + VisibleItems = filtered.ToList(); + } +} + +// Service (비즈니스 로직) +public class InventoryService : IUiService +{ + public IEnumerable FilterItems(InventoryCategoryType category, InventorySortType sortType) + { + // 복잡한 필터링과 정렬 로직을 Service로 이동 + } +} + +// View (UI 바인딩만) +public class MvvmInventoryView : MvvmBasePopupUi +{ + [SerializeField] private Text _categoryLabel; + [SerializeField] private Transform _slotParent; + + protected override void SetupBindings() + { + BindText(_categoryLabel, nameof(InventoryViewModel.CategoryDisplayText)); + } + + protected override void HandleCustomPropertyChanged(string propertyName) + { + if (propertyName == nameof(InventoryViewModel.VisibleItems)) + { + UpdateItemSlots(); // 복잡한 UI 업데이트는 여전히 필요 + } + } +} +``` + +## 마이그레이션 주의사항 + +### 1. 기존 코드와의 의존성 +- 기존 UI를 참조하는 다른 코드들 확인 +- 이벤트 시스템과의 연동 유지 +- ScriptableObject 설정 파일들과의 호환성 + +### 2. 성능 고려사항 +- PropertyChanged 이벤트의 과도한 발생 방지 +- UI 업데이트 배칭 활용 +- 메모리 누수 방지 (이벤트 핸들러 정리) + +### 3. 테스트 전략 +- ViewModel 단위 테스트 작성 +- 기존 UI와 새 UI 동시 테스트 +- 사용자 시나리오 기반 통합 테스트 + +## 마이그레이션 우선순위 추천 + +### 1차 마이그레이션 (높은 우선순위) +- **InventoryView**: 복잡한 상태 관리, 필터링, 정렬 +- **ShopView**: 상품 목록, 카테고리, 구매 로직 +- **MenuView**: 메뉴 관리, 레시피 선택 + +### 2차 마이그레이션 (중간 우선순위) +- **SettingsView**: 다양한 설정 옵션들 +- **StatisticsView**: 데이터 표시와 계산 + +### 3차 마이그레이션 (낮은 우선순위) +- **SimpleDialogUi**: 간단한 확인 다이얼로그들 +- **NotificationUi**: 알림 팝업들 + +이 가이드를 따라 점진적으로 UI를 MVVM으로 전환하면, 기존 시스템의 안정성을 유지하면서도 새로운 아키텍처의 이점을 얻을 수 있습니다. \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/MIGRATION_GUIDE.md.meta b/Assets/_DDD/_Scripts/GameUi/New/MIGRATION_GUIDE.md.meta new file mode 100644 index 000000000..423f0dde4 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/MIGRATION_GUIDE.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4d3c9e8511cb71c42b53aa3543216518 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_DDD/_Scripts/GameUi/New/README.md b/Assets/_DDD/_Scripts/GameUi/New/README.md new file mode 100644 index 000000000..1b09e6a8e --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/README.md @@ -0,0 +1,278 @@ +# Unity MVVM 시스템 가이드 + +Unity에서 MVVM(Model-View-ViewModel) 패턴을 구현한 시스템입니다. Attribute 기반 데이터 바인딩과 `nameof`를 활용한 타입 안전한 구조를 제공합니다. + +## 폴더 구조 + +``` +GameUi/New/ +├── ViewModels/ # ViewModel 클래스들 +│ ├── Base/ # 기본 ViewModel 클래스 +│ │ └── SimpleViewModel.cs +│ └── InventoryViewModel.cs # 예시 ViewModel +├── Views/ # View 클래스들 +│ └── Base/ +│ └── AutoBindView.cs # 자동 바인딩 View 기본 클래스 +├── Services/ # 서비스 계층 클래스들 +│ ├── IService.cs # 서비스 인터페이스 +│ └── InventoryService.cs # 예시 서비스 +├── Utils/ # 유틸리티 클래스들 +│ ├── BindToAttribute.cs # 바인딩 Attribute +│ └── BindingContext.cs # 바인딩 컨텍스트 +└── Converters/ # 값 변환기들 + ├── IValueConverter.cs # 컨버터 인터페이스 + └── CommonConverters.cs # 공통 컨버터들 +``` + +## 핵심 클래스 + +### 1. SimpleViewModel +- ViewModel의 기본 클래스 +- `INotifyPropertyChanged` 구현 +- 속성 변경 알림 자동 처리 +- 배치 업데이트 지원 + +### 2. IntegratedBaseUi +- UI의 기본 클래스 (BaseUi + MVVM 기능 통합) +- Attribute 기반 자동 바인딩 +- ViewModel과 UI 요소 자동 연결 + +### 3. BindToAttribute +- UI 요소를 ViewModel 속성에 바인딩 +- `nameof()` 사용으로 타입 안전성 보장 +- 컨버터 지원 + +## 사용법 + +### 1. ViewModel 생성 + +```csharp +namespace DDD.MVVM +{ + public class MyViewModel : SimpleViewModel + { + private string _title = "기본 제목"; + private int _count = 0; + private bool _isVisible = true; + + public string Title + { + get => _title; + set => SetField(ref _title, value); + } + + public int Count + { + get => _count; + set => SetField(ref _count, value); + } + + public bool IsVisible + { + get => _isVisible; + set => SetField(ref _isVisible, value); + } + + public string CountText => $"개수: {Count}"; + + public void IncrementCount() + { + Count++; + OnPropertyChanged(nameof(CountText)); + } + } +} +``` + +### 2. View 생성 + +```csharp +namespace DDD +{ + public class MyView : IntegratedBaseUi + { + [SerializeField, BindTo(nameof(MyViewModel.Title))] + private Text _titleText; + + [SerializeField, BindTo(nameof(MyViewModel.CountText))] + private Text _countText; + + [SerializeField, BindTo(nameof(MyViewModel.IsVisible))] + private GameObject _panel; + + // UI 이벤트 핸들러 + public void OnIncrementButtonClicked() + { + ViewModel.IncrementCount(); + } + + public void OnToggleVisibilityClicked() + { + ViewModel.IsVisible = !ViewModel.IsVisible; + } + } +} +``` + +### 3. 컨버터 사용 + +```csharp +[SerializeField, BindTo(nameof(MyViewModel.IsVisible), typeof(InvertBoolConverter))] +private GameObject _hiddenPanel; + +[SerializeField, BindTo(nameof(MyViewModel.Count), typeof(ItemCountConverter))] +private Text _formattedCountText; +``` + +## 기존 InventoryView 변환 예시 + +### 기존 코드 (InventoryView) +```csharp +public class InventoryView : MonoBehaviour +{ + private InventoryCategoryType _currentCategory = InventoryCategoryType.Food; + + public void UpdateCategoryView(InventoryCategoryType category) + { + _currentCategory = category; + // 복잡한 UI 업데이트 로직... + } +} +``` + +### MVVM 변환 후 + +**InventoryViewModel.cs** +```csharp +namespace DDD.MVVM +{ + public class InventoryViewModel : SimpleViewModel + { + private InventoryCategoryType _currentCategory = InventoryCategoryType.Food; + + public InventoryCategoryType CurrentCategory + { + get => _currentCategory; + set => SetField(ref _currentCategory, value); + } + + public string CategoryDisplayText => CurrentCategory switch + { + InventoryCategoryType.Food => "음식", + InventoryCategoryType.Drink => "음료", + _ => "전체" + }; + + public void SetCategory(InventoryCategoryType category) + { + CurrentCategory = category; + OnPropertyChanged(nameof(CategoryDisplayText)); + } + } +} +``` + +**InventoryView.cs** +```csharp +namespace DDD.MVVM +{ + public class InventoryView : AutoBindView + { + [SerializeField, BindTo(nameof(InventoryViewModel.CategoryDisplayText))] + private Text _categoryLabel; + + public void OnCategoryButtonClicked(int categoryIndex) + { + ViewModel.SetCategory((InventoryCategoryType)categoryIndex); + } + } +} +``` + +## 장점 + +### 1. 타입 안전성 +- `nameof()` 사용으로 컴파일 타임 검증 +- 속성명 변경 시 자동 업데이트 +- IDE IntelliSense 지원 + +### 2. Unity 통합성 +- Inspector에서 바인딩 확인 +- MonoBehaviour 패턴 유지 +- 기존 Unity 워크플로우와 호환 + +### 3. 유지보수성 +- View와 비즈니스 로직 분리 +- 테스트 가능한 ViewModel +- 재사용 가능한 컴포넌트 + +### 4. 개발 생산성 +- 자동 바인딩으로 보일러플레이트 코드 감소 +- 데이터 변경 시 UI 자동 업데이트 +- 일관된 개발 패턴 + +## 컨버터 목록 + +### 기본 컨버터들 +- `InvertBoolConverter`: 불린 값 반전 +- `ItemCountConverter`: 숫자를 "아이템 수: N" 형식으로 변환 +- `InventoryCategoryConverter`: 카테고리 열거형을 한국어로 변환 +- `CurrencyConverter`: 숫자를 통화 형식으로 변환 +- `PercentageConverter`: 소수를 백분율로 변환 + +### 커스텀 컨버터 생성 +```csharp +public class CustomConverter : IValueConverter +{ + public object Convert(object value) + { + // 변환 로직 구현 + return convertedValue; + } + + public object ConvertBack(object value) + { + // 역변환 로직 구현 (선택사항) + return originalValue; + } +} +``` + +## 베스트 프랙티스 + +### 1. ViewModel 설계 +- 단일 책임 원칙 준수 +- UI 관련 Unity API 직접 사용 금지 +- 계산된 속성 적극 활용 +- 이벤트를 통한 느슨한 결합 + +### 2. 바인딩 설정 +- `nameof()` 사용 필수 +- 적절한 컨버터 활용 +- 복잡한 로직은 ViewModel에서 처리 + +### 3. 성능 고려사항 +- 배치 업데이트 활용 +- 불필요한 PropertyChanged 이벤트 방지 +- 컬렉션 변경 시 효율적인 업데이트 + +## 마이그레이션 가이드 + +### 단계별 적용 방법 + +#### 1단계: 기존 View에서 ViewModel 분리 +1. View의 상태 변수들을 ViewModel로 이동 +2. 비즈니스 로직을 Service로 분리 +3. UI 업데이트 로직을 바인딩으로 대체 + +#### 2단계: 자동 바인딩 적용 +1. `AutoBindView` 상속 +2. UI 요소에 `BindTo` Attribute 추가 +3. 수동 UI 업데이트 코드 제거 + +#### 3단계: 최적화 및 리팩토링 +1. 컨버터 활용으로 로직 단순화 +2. 계산된 속성으로 중복 제거 +3. 이벤트 시스템과 통합 + +이 MVVM 시스템을 통해 Unity UI 개발의 생산성과 유지보수성을 크게 향상시킬 수 있습니다. \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/README.md.meta b/Assets/_DDD/_Scripts/GameUi/New/README.md.meta new file mode 100644 index 000000000..392b933fe --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5d5b08d15b2e43f4698c0f24977c2582 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_DDD/_Scripts/GameUi/New/Services.meta b/Assets/_DDD/_Scripts/GameUi/New/Services.meta new file mode 100644 index 000000000..4d31e16a4 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Services.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7196d7444953d844c9b0e893fb3e958a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_DDD/_Scripts/GameUi/New/Services/IService.cs b/Assets/_DDD/_Scripts/GameUi/New/Services/IService.cs new file mode 100644 index 000000000..4162b4604 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Services/IService.cs @@ -0,0 +1,62 @@ +namespace DDD.MVVM +{ + /// + /// 서비스 계층의 기본 인터페이스 + /// MVVM 패턴에서 비즈니스 로직을 담당하는 서비스들의 공통 인터페이스 + /// + public interface IService + { + /// + /// 서비스 초기화 + /// + void Initialize(); + + /// + /// 서비스 종료 시 리소스 정리 + /// + void Cleanup(); + } + + /// + /// 데이터 서비스 인터페이스 + /// 데이터 CRUD 작업을 담당하는 서비스들의 기본 인터페이스 + /// + /// 관리할 데이터 타입 + public interface IDataService : IService + { + /// + /// 데이터 로드 + /// + void LoadData(); + + /// + /// 데이터 저장 + /// + void SaveData(); + + /// + /// 특정 ID의 데이터 가져오기 + /// + /// 데이터 ID + /// 데이터 객체 또는 null + TData GetData(string id); + + /// + /// 모든 데이터 가져오기 + /// + /// 모든 데이터 컬렉션 + System.Collections.Generic.IEnumerable GetAllData(); + } + + /// + /// UI 서비스 인터페이스 + /// UI 관련 비즈니스 로직을 담당하는 서비스들의 기본 인터페이스 + /// + public interface IUiService : IService + { + /// + /// UI 상태 업데이트 + /// + void UpdateUiState(); + } +} \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Services/IService.cs.meta b/Assets/_DDD/_Scripts/GameUi/New/Services/IService.cs.meta new file mode 100644 index 000000000..ff2af3d05 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Services/IService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7f55f2be524632444aeebeb8b8965efc \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Services/InventoryService.cs b/Assets/_DDD/_Scripts/GameUi/New/Services/InventoryService.cs new file mode 100644 index 000000000..ca8212a86 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Services/InventoryService.cs @@ -0,0 +1,167 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace DDD.MVVM +{ + /// + /// 인벤토리 관련 비즈니스 로직을 담당하는 서비스 + /// 기존 InventoryView의 로직을 서비스 계층으로 분리 + /// + public class InventoryService : IUiService + { + private RestaurantManagementData _restaurantManagementData; + private RestaurantManagementState _restaurantManagementState; + + /// + /// 서비스 초기화 + /// + public void Initialize() + { + _restaurantManagementState = RestaurantState.Instance.ManagementState; + _restaurantManagementData = RestaurantData.Instance.ManagementData; + + Debug.Assert(_restaurantManagementData != null, "RestaurantManagementData is null"); + Debug.Assert(_restaurantManagementState != null, "RestaurantManagementState is null"); + } + + /// + /// 서비스 정리 + /// + public void Cleanup() + { + // 필요한 경우 리소스 정리 + } + + /// + /// UI 상태 업데이트 + /// + public void UpdateUiState() + { + // UI 상태 업데이트 로직 + } + + /// + /// 인벤토리 아이템 목록 가져오기 + /// + /// 인벤토리 아이템 ViewModel 목록 + public List GetInventoryItems() + { + return ItemViewModelFactory.CreateRestaurantManagementInventoryItem(); + } + + /// + /// 카테고리와 정렬 타입에 따라 필터링된 아이템 목록 가져오기 + /// + /// 카테고리 필터 + /// 정렬 타입 + /// 필터링 및 정렬된 아이템 목록 + public IEnumerable FilterItems(InventoryCategoryType category, InventorySortType sortType) + { + var items = GetInventoryItems(); + + // 카테고리 필터링 + var filtered = items.Where(item => MatchesCategory(item, category)); + + // 정렬 + return ApplySort(filtered, sortType); + } + + /// + /// 오늘의 메뉴에 등록되지 않은 아이템들만 필터링 + /// + /// 원본 아이템 목록 + /// 오늘의 메뉴에 등록되지 않은 아이템들 + public IEnumerable FilterOutTodayMenuItems(IEnumerable items) + { + return items.Where(item => + !(item.ItemType == ItemType.Recipe && _restaurantManagementState.IsContainTodayMenu(item.Id))); + } + + /// + /// 아이템이 특정 카테고리와 일치하는지 확인 + /// + /// 확인할 아이템 + /// 카테고리 + /// 일치 여부 + public bool MatchesCategory(ItemViewModel item, InventoryCategoryType category) + { + switch (category) + { + case InventoryCategoryType.Food: + if (item.ItemType != ItemType.Recipe) return false; + return DataManager.Instance.GetDataSo() + .TryGetDataById(item.Id, out var foodRecipe) && foodRecipe.RecipeType == RecipeType.FoodRecipe; + + case InventoryCategoryType.Drink: + if (item.ItemType != ItemType.Recipe) return false; + return DataManager.Instance.GetDataSo() + .TryGetDataById(item.Id, out var drinkRecipe) && drinkRecipe.RecipeType == RecipeType.DrinkRecipe; + + case InventoryCategoryType.Ingredient: + return item.ItemType == ItemType.Ingredient; + + case InventoryCategoryType.Cookware: + return DataManager.Instance.GetDataSo() + .TryGetDataById(item.Id, out var cookwareData); + + case InventoryCategoryType.Special: + return false; // 특수 아이템 로직 추가 필요 + + default: + return false; + } + } + + /// + /// 정렬 타입에 따라 아이템 목록 정렬 + /// + /// 정렬할 아이템 목록 + /// 정렬 타입 + /// 정렬된 아이템 목록 + public IEnumerable ApplySort(IEnumerable items, InventorySortType sortType) + { + return sortType switch + { + InventorySortType.NameAscending => items.OrderByDescending(item => item.HasItem).ThenBy(item => item.DisplayName), + InventorySortType.NameDescending => items.OrderByDescending(item => item.HasItem).ThenByDescending(item => item.DisplayName), + InventorySortType.QuantityAscending => items.OrderByDescending(item => item.HasItem).ThenBy(item => item.Count), + InventorySortType.QuantityDescending => items.OrderByDescending(item => item.HasItem).ThenByDescending(item => item.Count), + InventorySortType.None => items.OrderBy(item => item.Id), + _ => items + }; + } + + /// + /// 아이템이 아이템을 보유하고 있는지 확인 + /// + /// 확인할 아이템 목록 + /// 아이템을 보유한 목록 + public IEnumerable GetItemsWithStock(IEnumerable items) + { + return items.Where(item => item.HasItem); + } + + /// + /// 카테고리별 아이템 개수 가져오기 + /// + /// 카테고리 + /// 해당 카테고리의 보유 아이템 수 + public int GetItemCountByCategory(InventoryCategoryType category) + { + var filteredItems = FilterItems(category, InventorySortType.None); + var itemsWithStock = GetItemsWithStock(filteredItems); + return itemsWithStock.Count(); + } + + /// + /// 전체 인벤토리에서 보유한 아이템 총 개수 + /// + /// 보유 아이템 총 개수 + public int GetTotalItemCount() + { + var allItems = GetInventoryItems(); + return GetItemsWithStock(allItems).Count(); + } + } +} \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Services/InventoryService.cs.meta b/Assets/_DDD/_Scripts/GameUi/New/Services/InventoryService.cs.meta new file mode 100644 index 000000000..130fe7d1b --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Services/InventoryService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 682535d5577b54c499b1f52b4177d202 \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Utils.meta b/Assets/_DDD/_Scripts/GameUi/New/Utils.meta new file mode 100644 index 000000000..a9681e78a --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Utils.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0d5bef5ee11a4064e84fb4e318be2bee +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_DDD/_Scripts/GameUi/New/Utils/BindToAttribute.cs b/Assets/_DDD/_Scripts/GameUi/New/Utils/BindToAttribute.cs new file mode 100644 index 000000000..cdf9580c2 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Utils/BindToAttribute.cs @@ -0,0 +1,114 @@ +using System; + +namespace DDD.MVVM +{ + /// + /// UI 요소를 ViewModel 속성에 바인딩하기 위한 Attribute + /// Inspector에서 바인딩 정보를 시각적으로 확인할 수 있도록 지원 + /// + [System.AttributeUsage(System.AttributeTargets.Field)] + public class BindToAttribute : System.Attribute + { + /// + /// 바인딩할 ViewModel 속성의 경로 (nameof 사용 권장) + /// + public string PropertyPath { get; } + + /// + /// 값 변환기 타입 (선택사항) + /// + public System.Type ConverterType { get; } + + /// + /// 바인딩 Attribute 생성자 + /// + /// 바인딩할 속성 경로 (nameof 사용 권장) + /// 값 변환기 타입 (선택사항) + public BindToAttribute(string propertyPath, System.Type converterType = null) + { + PropertyPath = propertyPath; + ConverterType = converterType; + } + } + + // /// + // /// 타입 안전한 바인딩 Attribute (제네릭 버전) + // /// 특정 ViewModel 타입에 대한 바인딩을 명시적으로 지정 + // /// + // /// 바인딩할 ViewModel 타입 + // [System.AttributeUsage(System.AttributeTargets.Field)] + // public class BindToAttribute : System.Attribute where TViewModel : class + // { + // /// + // /// 바인딩할 ViewModel 속성의 경로 + // /// + // public string PropertyPath { get; } + // + // /// + // /// 값 변환기 타입 (선택사항) + // /// + // public System.Type ConverterType { get; } + // + // /// + // /// 타입 안전한 바인딩 Attribute 생성자 + // /// + // /// 바인딩할 속성 경로 + // /// 값 변환기 타입 (선택사항) + // public BindToAttribute(string propertyPath, System.Type converterType = null) + // { + // PropertyPath = propertyPath; + // ConverterType = converterType; + // } + // } + + /// + /// 컬렉션 바인딩을 위한 Attribute + /// 동적으로 생성되는 UI 요소들을 컬렉션에 바인딩 + /// + [System.AttributeUsage(System.AttributeTargets.Field)] + public class BindCollectionAttribute : System.Attribute + { + /// + /// 바인딩할 컬렉션 속성의 경로 + /// + public string PropertyPath { get; } + + /// + /// 아이템 프리팹의 필드명 또는 속성명 + /// + public string ItemPrefabReference { get; } + + /// + /// 컬렉션 바인딩 Attribute 생성자 + /// + /// 바인딩할 컬렉션 속성 경로 + /// 아이템 프리팹 참조 + public BindCollectionAttribute(string propertyPath, string itemPrefabReference = null) + { + PropertyPath = propertyPath; + ItemPrefabReference = itemPrefabReference; + } + } + + /// + /// 커맨드 바인딩을 위한 Attribute + /// 버튼 클릭 등의 이벤트를 ViewModel 메서드에 바인딩 + /// + [System.AttributeUsage(System.AttributeTargets.Field)] + public class BindCommandAttribute : System.Attribute + { + /// + /// 바인딩할 ViewModel 메서드 이름 + /// + public string MethodName { get; } + + /// + /// 커맨드 바인딩 Attribute 생성자 + /// + /// 바인딩할 메서드 이름 + public BindCommandAttribute(string methodName) + { + MethodName = methodName; + } + } +} \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Utils/BindToAttribute.cs.meta b/Assets/_DDD/_Scripts/GameUi/New/Utils/BindToAttribute.cs.meta new file mode 100644 index 000000000..a23282769 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Utils/BindToAttribute.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 279b3238907a3564f842594af646eab7 \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Utils/BindingContext.cs b/Assets/_DDD/_Scripts/GameUi/New/Utils/BindingContext.cs new file mode 100644 index 000000000..85904a96e --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Utils/BindingContext.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Reflection; +using UnityEngine; +using UnityEngine.UI; + +namespace DDD.MVVM +{ + /// + /// 바인딩 타겟 인터페이스 + /// UI 요소와 ViewModel 속성을 연결하는 역할 + /// + public interface IBindingTarget + { + /// + /// 바인딩된 속성의 경로 + /// + string PropertyPath { get; } + + /// + /// UI 요소의 값을 업데이트 + /// + /// 새로운 값 + void UpdateValue(object value); + } + + /// + /// Text 컴포넌트에 대한 바인딩 타겟 + /// + public class TextBindingTarget : IBindingTarget + { + private readonly Text _text; + public string PropertyPath { get; } + + public TextBindingTarget(Text text, string propertyPath) + { + _text = text; + PropertyPath = propertyPath; + } + + public void UpdateValue(object value) + { + if (_text != null) + _text.text = value?.ToString() ?? string.Empty; + } + } + + /// + /// Image 컴포넌트에 대한 바인딩 타겟 + /// + public class ImageBindingTarget : IBindingTarget + { + private readonly Image _image; + public string PropertyPath { get; } + + public ImageBindingTarget(Image image, string propertyPath) + { + _image = image; + PropertyPath = propertyPath; + } + + public void UpdateValue(object value) + { + if (_image != null && value is Sprite sprite) + _image.sprite = sprite; + } + } + + /// + /// GameObject의 활성화 상태에 대한 바인딩 타겟 + /// + public class ActiveBindingTarget : IBindingTarget + { + private readonly GameObject _gameObject; + public string PropertyPath { get; } + + public ActiveBindingTarget(GameObject gameObject, string propertyPath) + { + _gameObject = gameObject; + PropertyPath = propertyPath; + } + + public void UpdateValue(object value) + { + if (_gameObject != null) + _gameObject.SetActive(value is bool active && active); + } + } + + /// + /// Slider 컴포넌트에 대한 바인딩 타겟 + /// + public class SliderBindingTarget : IBindingTarget + { + private readonly Slider _slider; + public string PropertyPath { get; } + + public SliderBindingTarget(Slider slider, string propertyPath) + { + _slider = slider; + PropertyPath = propertyPath; + } + + public void UpdateValue(object value) + { + if (_slider != null && value is float floatValue) + _slider.value = floatValue; + } + } + + /// + /// 바인딩 컨텍스트 - ViewModel과 View 간의 데이터 바인딩을 관리 + /// + public class BindingContext + { + private readonly Dictionary> _bindings = new(); + private readonly Dictionary _converters = new(); + private INotifyPropertyChanged _dataContext; + + /// + /// 데이터 컨텍스트 (ViewModel) 설정 + /// + /// 바인딩할 ViewModel + public void SetDataContext(INotifyPropertyChanged dataContext) + { + if (_dataContext != null) + _dataContext.PropertyChanged -= OnPropertyChanged; + + _dataContext = dataContext; + + if (_dataContext != null) + { + _dataContext.PropertyChanged += OnPropertyChanged; + RefreshAllBindings(); + } + } + + /// + /// 속성 바인딩 추가 + /// + /// 바인딩할 속성 경로 + /// 바인딩 타겟 + /// 값 변환기 (선택사항) + public void Bind(string propertyPath, IBindingTarget target, IValueConverter converter = null) + { + if (!_bindings.ContainsKey(propertyPath)) + _bindings[propertyPath] = new List(); + + _bindings[propertyPath].Add(target); + + if (converter != null) + _converters[propertyPath] = converter; + + // 즉시 초기값 설정 + UpdateBinding(propertyPath); + } + + /// + /// 속성 변경 이벤트 핸들러 + /// + private void OnPropertyChanged(object sender, PropertyChangedEventArgs e) + { + UpdateBinding(e.PropertyName); + } + + /// + /// 특정 속성의 바인딩 업데이트 + /// + /// 업데이트할 속성 경로 + private void UpdateBinding(string propertyPath) + { + if (!_bindings.ContainsKey(propertyPath)) return; + + var value = GetPropertyValue(propertyPath); + + // 컨버터 적용 + if (_converters.TryGetValue(propertyPath, out var converter)) + value = converter.Convert(value); + + foreach (var target in _bindings[propertyPath]) + { + target.UpdateValue(value); + } + } + + /// + /// 속성 값 가져오기 (리플렉션 사용) + /// + /// 속성 경로 + /// 속성 값 + private object GetPropertyValue(string propertyPath) + { + if (_dataContext == null) return null; + + // 중첩 속성 지원 (예: "ItemData.Name") + var properties = propertyPath.Split('.'); + object current = _dataContext; + + foreach (var prop in properties) + { + if (current == null) return null; + + var property = current.GetType().GetProperty(prop); + current = property?.GetValue(current); + } + + return current; + } + + /// + /// 모든 바인딩 새로고침 + /// + private void RefreshAllBindings() + { + foreach (var propertyPath in _bindings.Keys) + { + UpdateBinding(propertyPath); + } + } + + /// + /// 리소스 정리 + /// + public void Dispose() + { + if (_dataContext != null) + _dataContext.PropertyChanged -= OnPropertyChanged; + + _bindings.Clear(); + _converters.Clear(); + } + } + + /// + /// 속성 경로 캐시 - 성능 최적화를 위한 리플렉션 결과 캐싱 + /// + public static class PropertyPathCache + { + private static readonly Dictionary> _cache = new(); + + /// + /// 캐시된 PropertyInfo 가져오기 + /// + /// 타입 + /// 속성 이름 + /// PropertyInfo + public static PropertyInfo GetProperty(Type type, string propertyName) + { + if (!_cache.ContainsKey(type)) + _cache[type] = new Dictionary(); + + if (!_cache[type].ContainsKey(propertyName)) + _cache[type][propertyName] = type.GetProperty(propertyName); + + return _cache[type][propertyName]; + } + } +} \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Utils/BindingContext.cs.meta b/Assets/_DDD/_Scripts/GameUi/New/Utils/BindingContext.cs.meta new file mode 100644 index 000000000..868e1b8a4 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Utils/BindingContext.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bff0e2748b37ec54a982f4bc8568d2fd \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Utils/InputPhase.cs b/Assets/_DDD/_Scripts/GameUi/New/Utils/InputPhase.cs new file mode 100644 index 000000000..0fbbc29b5 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Utils/InputPhase.cs @@ -0,0 +1,24 @@ +namespace DDD.MVVM +{ + /// + /// 입력 처리 단계를 나타내는 열거형 + /// 매직 스트링을 제거하고 타입 안전성을 제공 + /// + public enum InputPhaseType + { + /// + /// 입력이 시작됨 + /// + Started, + + /// + /// 입력이 수행됨 + /// + Performed, + + /// + /// 입력이 취소됨 + /// + Canceled + } +} \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Utils/InputPhase.cs.meta b/Assets/_DDD/_Scripts/GameUi/New/Utils/InputPhase.cs.meta new file mode 100644 index 000000000..1152c6f08 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Utils/InputPhase.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5c9b66b101f99e1458e01b9e0653935f \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/ViewModels.meta b/Assets/_DDD/_Scripts/GameUi/New/ViewModels.meta new file mode 100644 index 000000000..f30e4659d --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/ViewModels.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a394c2737bee3d645a8c74d1449d7176 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_DDD/_Scripts/GameUi/New/ViewModels/Base.meta b/Assets/_DDD/_Scripts/GameUi/New/ViewModels/Base.meta new file mode 100644 index 000000000..e97d0f1fa --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/ViewModels/Base.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c5e7000232c822247a01c4b7c288a6f4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_DDD/_Scripts/GameUi/New/ViewModels/Base/SimpleViewModel.cs b/Assets/_DDD/_Scripts/GameUi/New/ViewModels/Base/SimpleViewModel.cs new file mode 100644 index 000000000..4f7310265 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/ViewModels/Base/SimpleViewModel.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using UnityEngine; + +namespace DDD.MVVM +{ + public abstract class SimpleViewModel : MonoBehaviour, INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; + + protected virtual void Awake() { } + protected virtual void Start() { } + protected virtual void OnDestroy() { } + public virtual void Initialize() { } + public virtual void Cleanup() { } + + /// + /// PropertyChanged 이벤트 발생 + /// + /// 변경된 속성 이름 (자동으로 설정됨) + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + /// + /// 필드 값 변경 및 PropertyChanged 이벤트 발생 + /// + /// 필드 타입 + /// 변경할 필드 참조 + /// 새로운 값 + /// 속성 이름 (자동으로 설정됨) + /// 값이 실제로 변경되었는지 여부 + protected bool SetField(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + OnPropertyChanged(propertyName); + return true; + } + + /// + /// 배치 업데이트를 위한 플래그 + /// + private bool _isUpdating; + + /// + /// 배치 업데이트 중 보류된 알림들 + /// + private readonly HashSet _pendingNotifications = new(); + + /// + /// 배치 업데이트 시작 - 여러 속성 변경을 한 번에 처리 + /// + protected void BeginUpdate() => _isUpdating = true; + + /// + /// 배치 업데이트 종료 - 보류된 모든 알림을 처리 + /// + protected void EndUpdate() + { + _isUpdating = false; + if (_pendingNotifications.Count > 0) + { + foreach (var prop in _pendingNotifications) + OnPropertyChanged(prop); + _pendingNotifications.Clear(); + } + } + + /// + /// PropertyChanged 이벤트 발생 (배치 업데이트 고려) + /// + protected virtual void OnPropertyChangedInternal([CallerMemberName] string propertyName = null) + { + if (_isUpdating) + { + _pendingNotifications.Add(propertyName); + } + else + { + OnPropertyChanged(propertyName); + } + } + } +} \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/ViewModels/Base/SimpleViewModel.cs.meta b/Assets/_DDD/_Scripts/GameUi/New/ViewModels/Base/SimpleViewModel.cs.meta new file mode 100644 index 000000000..4b3d252df --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/ViewModels/Base/SimpleViewModel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2996e8c7ea7282e4685a79943083c29a \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/ViewModels/InventoryViewModel.cs b/Assets/_DDD/_Scripts/GameUi/New/ViewModels/InventoryViewModel.cs new file mode 100644 index 000000000..dea265beb --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/ViewModels/InventoryViewModel.cs @@ -0,0 +1,274 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using UnityEngine; + +namespace DDD.MVVM +{ + /// + /// 인벤토리 UI의 ViewModel + /// 기존 InventoryView의 상태와 로직을 MVVM 패턴으로 분리 + /// + public class InventoryViewModel : SimpleViewModel, IEventHandler, + IEventHandler, IEventHandler + { + [Header("Services")] + [SerializeField] private InventoryService _inventoryService; + + // Private fields for properties + private InventoryCategoryType _currentCategory = InventoryCategoryType.Food; + private InventorySortType _currentSortType = InventorySortType.None; + private List _allItems = new(); + private List _visibleItems = new(); + private ItemViewModel _selectedItem; + + /// + /// 현재 선택된 카테고리 + /// + public InventoryCategoryType CurrentCategory + { + get => _currentCategory; + set + { + if (SetField(ref _currentCategory, value)) + { + UpdateVisibleItems(); + // 연관된 계산된 속성들도 알림 + OnPropertyChanged(nameof(CategoryDisplayText)); + } + } + } + + /// + /// 현재 정렬 타입 + /// + public InventorySortType CurrentSortType + { + get => _currentSortType; + set + { + if (SetField(ref _currentSortType, value)) + { + UpdateVisibleItems(); + } + } + } + + /// + /// 모든 인벤토리 아이템 목록 + /// + public List AllItems + { + get => _allItems; + private set => SetField(ref _allItems, value); + } + + /// + /// 현재 필터링된 보이는 아이템 목록 + /// + public List VisibleItems + { + get => _visibleItems; + private set => SetField(ref _visibleItems, value); + } + + /// + /// 현재 선택된 아이템 + /// + public ItemViewModel SelectedItem + { + get => _selectedItem; + set => SetField(ref _selectedItem, value); + } + + // Computed Properties (계산된 속성들) + + /// + /// 카테고리 표시 텍스트 (한국어) + /// + public string CategoryDisplayText => CurrentCategory switch + { + InventoryCategoryType.Food => "음식", + InventoryCategoryType.Drink => "음료", + InventoryCategoryType.Ingredient => "재료", + InventoryCategoryType.Cookware => "조리도구", + InventoryCategoryType.Special => "특수", + _ => "전체" + }; + + /// + /// 보이는 아이템들 중 실제 보유한 아이템이 있는지 확인 + /// + public bool HasVisibleItems => VisibleItems.Any(item => item.HasItem); + + /// + /// 보이는 아이템들 중 실제 보유한 아이템 개수 + /// + public int VisibleItemCount => VisibleItems.Count(item => item.HasItem); + + /// + /// 아이템 개수 표시 텍스트 + /// + public string ItemCountText => $"아이템 수: {VisibleItemCount}"; + + /// + /// 빈 목록 메시지 표시 여부 + /// + public bool ShowEmptyMessage => !HasVisibleItems; + + /// + /// 첫 번째 유효한 아이템 (UI 포커스용) + /// + public ItemViewModel FirstValidItem => VisibleItems.FirstOrDefault(item => item.HasItem); + + protected override void Awake() + { + base.Awake(); + + if (_inventoryService == null) + _inventoryService = new InventoryService(); + } + + public override void Initialize() + { + base.Initialize(); + + _inventoryService.Initialize(); + LoadInventoryData(); + RegisterEvents(); + } + + public override void Cleanup() + { + base.Cleanup(); + + UnregisterEvents(); + _inventoryService?.Cleanup(); + } + + /// + /// 이벤트 등록 + /// + private void RegisterEvents() + { + EventBus.Register(this); + EventBus.Register(this); + EventBus.Register(this); + } + + /// + /// 이벤트 등록 해제 + /// + private void UnregisterEvents() + { + EventBus.Unregister(this); + EventBus.Unregister(this); + EventBus.Unregister(this); + } + + /// + /// 인벤토리 데이터 로드 + /// + private void LoadInventoryData() + { + AllItems = _inventoryService.GetInventoryItems(); + UpdateVisibleItems(); + } + + /// + /// 카테고리 설정 (UI에서 호출) + /// + /// 새 카테고리 + public void SetCategory(InventoryCategoryType category) + { + CurrentCategory = category; + } + + /// + /// 정렬 타입 설정 (UI에서 호출) + /// + /// 새 정렬 타입 + public void SetSortType(InventorySortType sortType) + { + CurrentSortType = sortType; + } + + /// + /// 보이는 아이템 목록 업데이트 + /// + private void UpdateVisibleItems() + { + BeginUpdate(); // 배치 업데이트 시작 + + // 서비스에서 필터링된 아이템 가져오기 + var filteredItems = _inventoryService.FilterItems(CurrentCategory, CurrentSortType); + + // 오늘의 메뉴에 등록된 아이템 제외 + var finalItems = _inventoryService.FilterOutTodayMenuItems(filteredItems); + + VisibleItems = finalItems.ToList(); + + // 관련된 계산된 속성들 알림 + OnPropertyChanged(nameof(HasVisibleItems)); + OnPropertyChanged(nameof(VisibleItemCount)); + OnPropertyChanged(nameof(ItemCountText)); + OnPropertyChanged(nameof(ShowEmptyMessage)); + OnPropertyChanged(nameof(FirstValidItem)); + + EndUpdate(); // 배치 업데이트 종료 + } + + /// + /// 아이템 선택 + /// + /// 선택할 아이템 + public void SelectItem(ItemViewModel item) + { + SelectedItem = item; + } + + /// + /// 특정 카테고리의 아이템 개수 가져오기 + /// + /// 카테고리 + /// 아이템 개수 + public int GetItemCountForCategory(InventoryCategoryType category) + { + return _inventoryService.GetItemCountByCategory(category); + } + + // Event Handlers + + public void Invoke(InventoryChangedEvent evt) + { + LoadInventoryData(); + } + + public void Invoke(TodayMenuAddedEvent evt) + { + UpdateVisibleItems(); + } + + public void Invoke(TodayMenuRemovedEvent evt) + { + UpdateVisibleItems(); + } + + // Unity Editor에서 테스트용 메서드들 +#if UNITY_EDITOR + [ContextMenu("Test Category Change")] + private void TestCategoryChange() + { + var nextCategory = (InventoryCategoryType)(((int)CurrentCategory + 1) % 5); + SetCategory(nextCategory); + } + + [ContextMenu("Test Sort Change")] + private void TestSortChange() + { + var nextSort = (InventorySortType)(((int)CurrentSortType + 1) % 5); + SetSortType(nextSort); + } +#endif + } +} \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/ViewModels/InventoryViewModel.cs.meta b/Assets/_DDD/_Scripts/GameUi/New/ViewModels/InventoryViewModel.cs.meta new file mode 100644 index 000000000..74c89acef --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/ViewModels/InventoryViewModel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 321a552f0b0773b4db1ab3dc95217719 \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Views.meta b/Assets/_DDD/_Scripts/GameUi/New/Views.meta new file mode 100644 index 000000000..dac16c089 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Views.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 36adabeb3767cf64684116798ff0ef30 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_DDD/_Scripts/GameUi/New/Views/Base.meta b/Assets/_DDD/_Scripts/GameUi/New/Views/Base.meta new file mode 100644 index 000000000..368529dc5 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Views/Base.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 76b62bf64be94ab4bb1e4b610da29fa4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_DDD/_Scripts/GameUi/New/Views/Base/AutoBindView.cs b/Assets/_DDD/_Scripts/GameUi/New/Views/Base/AutoBindView.cs new file mode 100644 index 000000000..2298041d0 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Views/Base/AutoBindView.cs @@ -0,0 +1,210 @@ +using System; +using System.ComponentModel; +using System.Linq; +using System.Reflection; +using UnityEngine; +using UnityEngine.UI; + +namespace DDD.MVVM +{ + /// + /// 자동 바인딩을 지원하는 View 기본 클래스 + /// Attribute를 통해 설정된 바인딩을 자동으로 처리 + /// + /// 바인딩할 ViewModel 타입 + public abstract class AutoBindView : MonoBehaviour where TViewModel : SimpleViewModel + { + [SerializeField] protected TViewModel _viewModel; + protected BindingContext _bindingContext; + + /// + /// ViewModel 인스턴스 + /// + public TViewModel ViewModel => _viewModel; + + protected virtual void Awake() + { + if (_viewModel == null) + _viewModel = GetComponent(); + + _bindingContext = new BindingContext(); + + SetupAutoBindings(); + } + + protected virtual void OnEnable() + { + if (_viewModel != null && _bindingContext != null) + { + _bindingContext.SetDataContext(_viewModel); + _viewModel.PropertyChanged += OnViewModelPropertyChanged; + } + } + + protected virtual void OnDisable() + { + if (_viewModel != null) + { + _viewModel.PropertyChanged -= OnViewModelPropertyChanged; + } + } + + protected virtual void OnDestroy() + { + _bindingContext?.Dispose(); + } + + /// + /// Attribute 기반 자동 바인딩 설정 + /// + private void SetupAutoBindings() + { + var fields = GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public) + .Where(f => f.GetCustomAttribute() != null); + + foreach (var field in fields) + { + var bindAttribute = field.GetCustomAttribute(); + SetupBinding(field, bindAttribute); + } + + // 컬렉션 바인딩 설정 + var collectionFields = GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public) + .Where(f => f.GetCustomAttribute() != null); + + foreach (var field in collectionFields) + { + var bindAttribute = field.GetCustomAttribute(); + SetupCollectionBinding(field, bindAttribute); + } + } + + /// + /// 개별 필드의 바인딩 설정 + /// + /// 바인딩할 필드 + /// 바인딩 Attribute + private void SetupBinding(FieldInfo field, BindToAttribute bindAttribute) + { + var target = field.GetValue(this); + + IValueConverter converter = null; + if (bindAttribute.ConverterType != null) + { + converter = Activator.CreateInstance(bindAttribute.ConverterType) as IValueConverter; + } + + // UI 컴포넌트 타입별 바인딩 타겟 생성 + IBindingTarget bindingTarget = target switch + { + Text text => new TextBindingTarget(text, bindAttribute.PropertyPath), + Image image => new ImageBindingTarget(image, bindAttribute.PropertyPath), + GameObject gameObject => new ActiveBindingTarget(gameObject, bindAttribute.PropertyPath), + Slider slider => new SliderBindingTarget(slider, bindAttribute.PropertyPath), + _ => null + }; + + if (bindingTarget != null) + { + _bindingContext.Bind(bindAttribute.PropertyPath, bindingTarget, converter); + } + } + + /// + /// 컬렉션 바인딩 설정 + /// + /// 바인딩할 필드 + /// 바인딩 Attribute + private void SetupCollectionBinding(FieldInfo field, BindCollectionAttribute bindAttribute) + { + var target = field.GetValue(this); + + if (target is Transform parent) + { + // 컬렉션 바인딩은 별도 구현이 필요한 복잡한 기능으로 + // 현재는 기본 구조만 제공 + Debug.Log($"Collection binding for {bindAttribute.PropertyPath} is set up on {parent.name}"); + } + } + + /// + /// ViewModel 속성 변경 이벤트 핸들러 + /// 추가적인 커스텀 로직이 필요한 경우 오버라이드 + /// + /// 이벤트 발신자 + /// 속성 변경 정보 + protected virtual void OnViewModelPropertyChanged(object sender, PropertyChangedEventArgs e) + { + // 자동 바인딩으로 처리되지 않는 특별한 속성들의 커스텀 처리 + HandleCustomPropertyChanged(e.PropertyName); + } + + /// + /// 커스텀 속성 변경 처리 (하위 클래스에서 오버라이드) + /// + /// 변경된 속성 이름 + protected virtual void HandleCustomPropertyChanged(string propertyName) + { + // 하위 클래스에서 구현 + } + + /// + /// 수동 바인딩 헬퍼 메서드들 + /// Attribute 사용이 어려운 경우 코드로 바인딩 설정 + /// + + protected void BindText(Text text, string propertyPath, IValueConverter converter = null) + { + var target = new TextBindingTarget(text, propertyPath); + _bindingContext?.Bind(propertyPath, target, converter); + } + + protected void BindImage(Image image, string propertyPath, IValueConverter converter = null) + { + var target = new ImageBindingTarget(image, propertyPath); + _bindingContext?.Bind(propertyPath, target, converter); + } + + protected void BindActive(GameObject gameObject, string propertyPath, IValueConverter converter = null) + { + var target = new ActiveBindingTarget(gameObject, propertyPath); + _bindingContext?.Bind(propertyPath, target, converter); + } + + protected void BindSlider(Slider slider, string propertyPath, IValueConverter converter = null) + { + var target = new SliderBindingTarget(slider, propertyPath); + _bindingContext?.Bind(propertyPath, target, converter); + } + + /// + /// ViewModel 메서드 호출 헬퍼 + /// UI 이벤트에서 ViewModel 메서드를 쉽게 호출 + /// + /// 호출할 메서드 이름 + /// 메서드 매개변수 + protected void InvokeViewModelMethod(string methodName, params object[] parameters) + { + if (_viewModel == null) return; + + var method = _viewModel.GetType().GetMethod(methodName); + method?.Invoke(_viewModel, parameters); + } + + /// + /// ViewModel 속성 직접 설정 헬퍼 + /// + /// 속성 이름 + /// 설정할 값 + protected void SetViewModelProperty(string propertyName, object value) + { + if (_viewModel == null) return; + + var property = _viewModel.GetType().GetProperty(propertyName); + if (property != null && property.CanWrite) + { + property.SetValue(_viewModel, value); + } + } + } +} \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Views/Base/AutoBindView.cs.meta b/Assets/_DDD/_Scripts/GameUi/New/Views/Base/AutoBindView.cs.meta new file mode 100644 index 000000000..36e96d1a7 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Views/Base/AutoBindView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 738101122cf3fb74e99b244165797ab8 \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBasePopupUi.cs b/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBasePopupUi.cs new file mode 100644 index 000000000..f5139fa52 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBasePopupUi.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.EventSystems; +using DDD.MVVM; +using Sirenix.OdinInspector; +using UnityEngine.InputSystem; + +namespace DDD +{ + public abstract class IntegratedBasePopupUi : IntegratedBaseUi + where TInputEnum : Enum + where TViewModel : SimpleViewModel + { + [SerializeField, Required] protected BaseUiActionsInputBinding _uiActionsInputBinding; + + protected readonly List<(InputAction action, Action handler)> _registeredHandlers = + new(); + + public InputActionMaps InputActionMaps => _uiActionsInputBinding.InputActionMaps; + public bool IsTopPopup => UiManager.Instance.PopupUiState.IsTopPopup(this as BasePopupUi); + + protected override void Awake() + { + base.Awake(); + + // BasePopupUi의 기본값 적용 + _enableBlockImage = true; + } + + protected override void OnEnable() + { + base.OnEnable(); + } + + protected override void Update() + { + base.Update(); + + // BasePopupUi의 Update 로직 구현 + if (IsOpenPanel() == false) return; + + var currentSelectedGameObject = EventSystem.current.currentSelectedGameObject; + if (currentSelectedGameObject == null || currentSelectedGameObject.activeInHierarchy == false) + { + var initialSelected = GetInitialSelected(); + if (initialSelected != null) + { + EventSystem.current.SetSelectedGameObject(initialSelected); + } + } + } + + protected abstract GameObject GetInitialSelected(); + + protected override void TryRegister() + { + base.TryRegister(); + + // PopupUi의 입력 바인딩 등록 + foreach (var actionEnum in _uiActionsInputBinding.BindingActions.GetFlags()) + { + if (actionEnum.Equals(default(TInputEnum))) continue; + + var inputAction = + InputManager.Instance.GetAction(_uiActionsInputBinding.InputActionMaps, actionEnum.ToString()); + if (inputAction == null) continue; + + var startedHandler = new Action(context => + { + OnInputStarted(actionEnum, context); + }); + inputAction.started += startedHandler; + + var performedHandler = new Action(context => + { + OnInputPerformed(actionEnum, context); + }); + inputAction.performed += performedHandler; + + var canceledHandler = new Action(context => + { + OnInputCanceled(actionEnum, context); + }); + inputAction.canceled += canceledHandler; + + _registeredHandlers.Add((inputAction, startedHandler)); + _registeredHandlers.Add((inputAction, performedHandler)); + _registeredHandlers.Add((inputAction, canceledHandler)); + } + } + + protected override void TryUnregister() + { + base.TryUnregister(); + + // 입력 핸들러 해제 + foreach (var (action, handler) in _registeredHandlers) + { + if (action != null) + { + action.started -= handler; + action.performed -= handler; + action.canceled -= handler; + } + } + + _registeredHandlers.Clear(); + } + + // 입력 처리 메서드들 + protected virtual bool OnInputStarted(TInputEnum actionEnum, InputAction.CallbackContext context) => IsTopPopup; + + protected virtual bool OnInputPerformed(TInputEnum actionEnum, InputAction.CallbackContext context) => + IsTopPopup; + + protected virtual bool OnInputCanceled(TInputEnum actionEnum, InputAction.CallbackContext context) => + IsTopPopup; + + + public virtual void Open(OpenPopupUiEvent evt) + { + OpenPanel(); + + var initialSelected = GetInitialSelected(); + if (initialSelected != null) + { + EventSystem.current.SetSelectedGameObject(initialSelected); + } + + transform.SetAsLastSibling(); + + if (IsTopPopup) + { + InputManager.Instance.SwitchCurrentActionMap(_uiActionsInputBinding.InputActionMaps); + } + } + + public virtual void Close() + { + var evt = GameEvents.ClosePopupUiEvent; + evt.UiType = GetType(); + EventBus.Broadcast(evt); + } + } +} \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBasePopupUi.cs.meta b/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBasePopupUi.cs.meta new file mode 100644 index 000000000..23e1d5306 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBasePopupUi.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3ef87d5c2f3d82e488302056ac09a287 \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBaseUi.cs b/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBaseUi.cs new file mode 100644 index 000000000..0f2195045 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBaseUi.cs @@ -0,0 +1,248 @@ +using System; +using System.ComponentModel; +using System.Linq; +using System.Reflection; +using UnityEngine; +using UnityEngine.UI; +using DDD.MVVM; + +namespace DDD +{ + public abstract class IntegratedBaseUi : MonoBehaviour where TViewModel : SimpleViewModel + { + [SerializeField] protected bool _enableBlockImage; + + protected CanvasGroup _canvasGroup; + protected GameObject _blockImage; + protected GameObject _panel; + protected BindingContext _bindingContext; + protected TViewModel _viewModel; + + public virtual bool IsBlockingTime => false; + public virtual bool IsOpen => _panel != null && _panel.activeSelf; + + protected virtual void Awake() + { + _canvasGroup = GetComponent(); + _panel = transform.Find(CommonConstants.Panel)?.gameObject; + _blockImage = transform.Find(CommonConstants.BlockImage)?.gameObject; + + if (_viewModel == null) + _viewModel = GetComponent(); + + _bindingContext = new BindingContext(); + SetupAutoBindings(); + SetupBindings(); + } + + protected virtual void OnEnable() + { + if (_viewModel && _bindingContext != null) + { + _bindingContext.SetDataContext(_viewModel); + _viewModel.PropertyChanged += OnViewModelPropertyChanged; + } + } + + protected virtual void Start() + { + TryRegister(); + ClosePanel(); + } + + protected virtual void Update() + { + + } + + protected virtual void OnDisable() + { + if (_viewModel != null) + { + _viewModel.PropertyChanged -= OnViewModelPropertyChanged; + } + } + + protected virtual void OnDestroy() + { + TryUnregister(); + _bindingContext?.Dispose(); + } + + protected virtual void TryRegister() { } + protected virtual void TryUnregister() { } + + // BaseUi 메서드들을 직접 구현 + public virtual void OpenPanel() + { + if (_enableBlockImage) + { + _blockImage.SetActive(true); + } + + _panel.SetActive(true); + _viewModel?.Initialize(); + } + + public virtual void ClosePanel() + { + if (_enableBlockImage) + { + _blockImage.SetActive(false); + } + + _panel.SetActive(false); + _viewModel?.Cleanup(); + } + + public virtual void SetUiInteractable(bool active) + { + if (_canvasGroup != null) + { + _canvasGroup.interactable = active; + _canvasGroup.blocksRaycasts = active; + } + } + + public bool IsOpenPanel() => _panel && _panel.activeInHierarchy; + + /// + /// Attribute 기반 자동 바인딩 설정 + /// + private void SetupAutoBindings() + { + var fields = GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public) + .Where(f => f.GetCustomAttribute() != null); + + foreach (var field in fields) + { + var bindAttribute = field.GetCustomAttribute(); + SetupBinding(field, bindAttribute); + } + + // 컬렉션 바인딩 설정 + var collectionFields = GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public) + .Where(f => f.GetCustomAttribute() != null); + + foreach (var field in collectionFields) + { + var bindAttribute = field.GetCustomAttribute(); + SetupCollectionBinding(field, bindAttribute); + } + } + + /// + /// 개별 필드의 바인딩 설정 + /// + private void SetupBinding(FieldInfo field, BindToAttribute bindAttribute) + { + var target = field.GetValue(this); + + IValueConverter converter = null; + if (bindAttribute.ConverterType != null) + { + converter = Activator.CreateInstance(bindAttribute.ConverterType) as IValueConverter; + } + + // UI 컴포넌트 타입별 바인딩 타겟 생성 + IBindingTarget bindingTarget = target switch + { + Text text => new TextBindingTarget(text, bindAttribute.PropertyPath), + Image image => new ImageBindingTarget(image, bindAttribute.PropertyPath), + GameObject go => new ActiveBindingTarget(go, bindAttribute.PropertyPath), + Slider slider => new SliderBindingTarget(slider, bindAttribute.PropertyPath), + _ => null + }; + + if (bindingTarget != null) + { + _bindingContext.Bind(bindAttribute.PropertyPath, bindingTarget, converter); + } + } + + /// + /// 컬렉션 바인딩 설정 + /// + private void SetupCollectionBinding(FieldInfo field, BindCollectionAttribute bindAttribute) + { + var target = field.GetValue(this); + + if (target is Transform parent) + { + // 컬렉션 바인딩 로직 (필요시 확장) + Debug.Log($"Collection binding for {bindAttribute.PropertyPath} is set up on {parent.name}"); + } + } + + /// + /// 추가 바인딩 설정 - 하위 클래스에서 구현 + /// + protected virtual void SetupBindings() { } + + /// + /// ViewModel 속성 변경 이벤트 핸들러 + /// + protected virtual void OnViewModelPropertyChanged(object sender, PropertyChangedEventArgs e) + { + HandleCustomPropertyChanged(e.PropertyName); + } + + /// + /// 커스텀 속성 변경 처리 (하위 클래스에서 오버라이드) + /// + protected virtual void HandleCustomPropertyChanged(string propertyName) + { + // 하위 클래스에서 구현 + } + + // 수동 바인딩 헬퍼 메서드들 + protected void BindText(Text text, string propertyPath, IValueConverter converter = null) + { + var target = new TextBindingTarget(text, propertyPath); + _bindingContext?.Bind(propertyPath, target, converter); + } + + protected void BindImage(Image image, string propertyPath, IValueConverter converter = null) + { + var target = new ImageBindingTarget(image, propertyPath); + _bindingContext?.Bind(propertyPath, target, converter); + } + + protected void BindActive(GameObject gameObject, string propertyPath, IValueConverter converter = null) + { + var target = new ActiveBindingTarget(gameObject, propertyPath); + _bindingContext?.Bind(propertyPath, target, converter); + } + + protected void BindSlider(Slider slider, string propertyPath, IValueConverter converter = null) + { + var target = new SliderBindingTarget(slider, propertyPath); + _bindingContext?.Bind(propertyPath, target, converter); + } + + /// + /// ViewModel 메서드 호출 헬퍼 + /// + protected void InvokeViewModelMethod(string methodName, params object[] parameters) + { + if (_viewModel == null) return; + + var method = _viewModel.GetType().GetMethod(methodName); + method?.Invoke(_viewModel, parameters); + } + + /// + /// ViewModel 속성 설정 헬퍼 + /// + protected void SetViewModelProperty(string propertyName, object value) + { + if (_viewModel == null) return; + + var property = _viewModel.GetType().GetProperty(propertyName); + if (property != null && property.CanWrite) + { + property.SetValue(_viewModel, value); + } + } + } +} \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBaseUi.cs.meta b/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBaseUi.cs.meta new file mode 100644 index 000000000..ed069a47f --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Views/Base/IntegratedBaseUi.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 868322e05b33bdd4cbbe3e1495fe359b \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Views/Examples.meta b/Assets/_DDD/_Scripts/GameUi/New/Views/Examples.meta new file mode 100644 index 000000000..53babe302 --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Views/Examples.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: abb1dd67b48daeb4f968a2641cf7b4a3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_DDD/_Scripts/GameUi/New/Views/Examples/IntegratedInventoryView.cs b/Assets/_DDD/_Scripts/GameUi/New/Views/Examples/IntegratedInventoryView.cs new file mode 100644 index 000000000..344ad881d --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Views/Examples/IntegratedInventoryView.cs @@ -0,0 +1,221 @@ +using UnityEngine; +using UnityEngine.UI; +using DDD.MVVM; + +namespace DDD +{ + /// + /// IntegratedPopupUi를 사용한 인벤토리 뷰 예시 + /// 상속 대신 컴포지션 기반으로 모든 기능을 통합하여 구현 + /// Attribute 기반 자동 바인딩을 적극 활용 + /// + public class IntegratedInventoryView : IntegratedBasePopupUi + { + [Header("UI References")] + // Attribute를 통한 자동 바인딩 설정 + [SerializeField, BindTo(nameof(InventoryViewModel.CategoryDisplayText))] + private Text _categoryLabel; + + [SerializeField, BindTo(nameof(InventoryViewModel.ItemCountText))] + private Text _itemCountLabel; + + [SerializeField, BindTo(nameof(InventoryViewModel.ShowEmptyMessage))] + private GameObject _emptyMessage; + + // 수동 바인딩이 필요한 복잡한 UI 요소들 + [SerializeField] private Transform _slotParent; + [SerializeField] private Button[] _categoryButtons; + [SerializeField] private Button[] _sortButtons; + + [Header("Prefab References")] + [SerializeField] private GameObject _itemSlotPrefab; + + protected override GameObject GetInitialSelected() + { + // ViewModel의 FirstValidItem을 활용하여 초기 선택 UI 결정 + var firstItem = _viewModel?.FirstValidItem; + if (firstItem != null) + { + return FindSlotGameObject(firstItem); + } + + return _categoryButtons?.Length > 0 ? _categoryButtons[0].gameObject : null; + } + + /// + /// 수동 바인딩이 필요한 복잡한 UI 요소들 설정 + /// Attribute로 처리하기 어려운 컬렉션이나 복잡한 로직이 필요한 경우 + /// + protected override void SetupBindings() + { + // 복잡한 컬렉션 바인딩은 수동으로 처리 + // BindCollection은 아직 완전 구현되지 않았으므로 HandleCustomPropertyChanged에서 처리 + } + + protected override void HandleCustomPropertyChanged(string propertyName) + { + switch (propertyName) + { + case nameof(InventoryViewModel.VisibleItems): + UpdateItemSlots(); + break; + + case nameof(InventoryViewModel.CurrentCategory): + UpdateCategoryButtons(); + break; + + case nameof(InventoryViewModel.CurrentSortType): + UpdateSortButtons(); + break; + + case nameof(InventoryViewModel.FirstValidItem): + UpdateInitialSelection(); + break; + } + } + + /// + /// 아이템 슬롯 UI 업데이트 + /// + private void UpdateItemSlots() + { + if (_viewModel?.VisibleItems == null) return; + + // 기존 슬롯들 정리 + ClearSlots(); + + int siblingIndex = 0; + foreach (var itemViewModel in _viewModel.VisibleItems) + { + if (!itemViewModel.HasItem) continue; + + // 아이템 슬롯 UI 생성 + var slotGameObject = Instantiate(_itemSlotPrefab, _slotParent); + var slotUi = slotGameObject.GetComponent(); + + // 기존 방식대로 슬롯 초기화 + slotUi.Initialize(itemViewModel, new InventorySlotUiStrategy()); + slotGameObject.name = $"ItemSlotUi_{itemViewModel.Id}"; + + // 인터랙터 설정 + var interactor = slotGameObject.GetComponent(); + if (itemViewModel.ItemType == ItemType.Recipe) + { + interactor.Initialize(TodayMenuEventType.Add, new TodayMenuInteractorStrategy()); + } + else if (DataManager.Instance.GetDataSo().TryGetDataById(itemViewModel.Id, out var cookwareData)) + { + interactor.Initialize(TodayMenuEventType.Add, new TodayCookwareInteractorStrategy()); + } + + slotGameObject.transform.SetSiblingIndex(siblingIndex++); + } + } + + /// + /// 카테고리 버튼 상태 업데이트 + /// + private void UpdateCategoryButtons() + { + if (_categoryButtons == null || _viewModel == null) return; + + for (int i = 0; i < _categoryButtons.Length; i++) + { + var button = _categoryButtons[i]; + var isSelected = (int)_viewModel.CurrentCategory == i; + + button.interactable = !isSelected; + } + } + + /// + /// 정렬 버튼 상태 업데이트 + /// + private void UpdateSortButtons() + { + if (_sortButtons == null || _viewModel == null) return; + + for (int i = 0; i < _sortButtons.Length; i++) + { + var button = _sortButtons[i]; + var isSelected = (int)_viewModel.CurrentSortType == i; + + button.interactable = !isSelected; + } + } + + /// + /// 초기 선택 UI 업데이트 + /// + private void UpdateInitialSelection() + { + var initialSelected = GetInitialSelected(); + if (initialSelected != null) + { + UnityEngine.EventSystems.EventSystem.current.SetSelectedGameObject(initialSelected); + } + } + + /// + /// 기존 슬롯들 정리 + /// + private void ClearSlots() + { + foreach (Transform child in _slotParent) + { + Destroy(child.gameObject); + } + } + + /// + /// ItemViewModel에 해당하는 UI GameObject 찾기 + /// + private GameObject FindSlotGameObject(ItemViewModel itemViewModel) + { + foreach (Transform child in _slotParent) + { + if (child.name == $"ItemSlotUi_{itemViewModel.Id}") + { + return child.gameObject; + } + } + return null; + } + + // UI 이벤트 핸들러들 - ViewModel 메서드 호출 + public void OnCategoryButtonClicked(int categoryIndex) + { + _viewModel?.SetCategory((InventoryCategoryType)categoryIndex); + } + + public void OnSortButtonClicked(int sortIndex) + { + _viewModel?.SetSortType((InventorySortType)sortIndex); + } + + public void OnItemSlotClicked(ItemViewModel item) + { + _viewModel?.SelectItem(item); + } + + // 입력 처리 - ViewModel로 위임 + protected override bool OnInputPerformed(RestaurantUiActions actionEnum, UnityEngine.InputSystem.InputAction.CallbackContext context) + { + var isHandled = base.OnInputPerformed(actionEnum, context); + + // 특별한 입력 처리 로직이 필요한 경우 여기에 추가 + if (isHandled) + { + switch (actionEnum) + { + case RestaurantUiActions.Cancel: + Close(); + break; + // 기타 액션들은 ViewModel로 위임됨 + } + } + + return isHandled; + } + } +} \ No newline at end of file diff --git a/Assets/_DDD/_Scripts/GameUi/New/Views/Examples/IntegratedInventoryView.cs.meta b/Assets/_DDD/_Scripts/GameUi/New/Views/Examples/IntegratedInventoryView.cs.meta new file mode 100644 index 000000000..4c0ae5b0a --- /dev/null +++ b/Assets/_DDD/_Scripts/GameUi/New/Views/Examples/IntegratedInventoryView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 12f3141cdd485054e8c73be9d549feb7 \ No newline at end of file