menu 개발 중. 언어 변경 시 반영 않됨

This commit is contained in:
logonkhi
2025-06-11 19:24:08 +09:00
parent cd8c5e177b
commit 2614470f13
92 changed files with 1199947 additions and 188 deletions

View File

@@ -1,11 +1,8 @@
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Unity.VisualScripting.Antlr3.Runtime;
using UnityEngine;
using UVC.Log;
namespace UVC.Data
@@ -378,7 +375,7 @@ namespace UVC.Data
private object ConvertJTokenToObject(JToken token)
{
if (token == null) return null;
switch (token.Type)
{
case JTokenType.Object:
@@ -504,7 +501,7 @@ namespace UVC.Data
// 예: maskTemplates.Count가 3이고, sourceArray.Count가 5인 경우
// 0, 1, 2 인덱스는 maskTemplates[0], maskTemplates[1], maskTemplates[2] 사용
// 3, 4 인덱스는 저장 않함
if( i >= maskTemplates.Count)
if (i >= maskTemplates.Count)
continue;
int templateIndex = Math.Min(i, maskTemplates.Count - 1);
maskTemplate = maskTemplates[templateIndex];

View File

@@ -233,15 +233,16 @@ namespace UVC.Data
{
try
{
string? responseData = info.ResponseMask.Apply(result);
// responseData가 null인 경우는 올바른 응답이 아니므로 처리하지 않음
if (responseData == null)
HttpResponseResult responseResult = info.ResponseMask.Apply(result);
// 응답 마스크 적용 결과가 성공이 아니면 실패 핸들러 호출 후 반환
if (!responseResult.IsSuccess)
{
throw new Exception($"유효하지 않은 response data. {result}");
info.FailHandler?.Invoke(responseResult.Message!);
return;
}
else
{
result = responseData.Trim();
result = responseResult.Data!.Trim();
}
@@ -299,11 +300,11 @@ namespace UVC.Data
// 갱신 된 데이터가 있는 경우 핸들러 호출
if (info.UpdatedDataOnly)
{
if (dataObject != null && dataObject.UpdatedCount > 0) info.Handler?.Invoke(dataObject);
if (dataObject != null && dataObject.UpdatedCount > 0) info.SuccessHandler?.Invoke(dataObject);
}
else
{
info.Handler?.Invoke(dataObject);
info.SuccessHandler?.Invoke(dataObject);
}
return;
}

View File

@@ -19,7 +19,8 @@ namespace UVC.Data
/// <code>
/// var pipelineInfo = new HttpPipeLineInfo("https://api.example.com/data", "GET")
/// .setDataMapper(new DataMapper(dataMask))
/// .setHandler(data => Console.WriteLine(data))
/// .setSuccessHandler(data => Console.WriteLine(data)) // 성공 핸들러 예시
/// .setFailHandler(errorData => Console.Error.WriteLine(errorData)) // 실패 핸들러 예시
/// .setRetry(5, 2000)
/// .setRepeat(true, 10, 5000);
/// </code>
@@ -30,12 +31,13 @@ namespace UVC.Data
private string _method;
private Dictionary<string, string>? _headers = null;
private string? _body = null;
private Action<IDataObject?>? _handler = null;
private Action<IDataObject?>? _successhandler = null; //요청 성공 시 호출될 내부 핸들러입니다.
private Action<string>? _failhandler = null; //요청 실패 시 호출될 내부 핸들러입니다.
private bool _repeat = false; // 반복 실행 여부
private int _repeatCount = 0; // 반복 횟수, 0은 무한 반복
private int _repeatInterval = 1000; // 반복 간격 (ms)
private DataMapper? _dataMapper = null; // 데이터 매퍼
private ResponseMask _responseMask = new ResponseMask(); // response 데이터 성공 여부와 데이터를 구분하는 마스크
private HttpResponseMask _responseMask = new HttpResponseMask(); // response 데이터 성공 여부와 데이터를 구분하는 마스크
private int _maxRetryCount = 3;
private int _retryDelay = 1000; // 밀리초
private bool _updatedDataOnly = false; // 업데이트된 데이터만 받을 여부
@@ -61,9 +63,14 @@ namespace UVC.Data
public string? Body => _body;
/// <summary>
/// 요청 완료 후 호출될 핸들러
/// 요청 성공 시 호출될 핸들러
/// </summary>
public Action<IDataObject?>? Handler => _handler;
public Action<IDataObject?>? SuccessHandler => _successhandler;
/// <summary>
/// 요청 실패 시 호출될 핸들러
/// </summary>
public Action<string>? FailHandler => _failhandler;
/// <summary>
/// 반복 실행 여부
@@ -86,9 +93,10 @@ namespace UVC.Data
public DataMapper? DataMapper => _dataMapper;
/// <summary>
/// response에 적용되는 데이터 마스크
/// HTTP 응답의 성공 여부를 확인하고, 성공 시 실제 데이터 페이로드를 추출하는 데 사용되는 <see cref="Data.HttpResponseMask"/>입니다.
/// 이 객체에 정의된 규칙에 따라 원시 응답 문자열이 처리됩니다.
/// </summary>
public ResponseMask ResponseMask => _responseMask;
public HttpResponseMask ResponseMask => _responseMask;
/// <summary>
/// 최대 재시도 횟수
@@ -133,11 +141,11 @@ namespace UVC.Data
}
/// <summary>
/// HTTP 파이프라인에 적용할 response 마스크를 설정하고 업데이트된 파이프라인 구성을 반환합니다.
/// HTTP 파이프라인에 적용할 ResponseMask를 설정하고 업데이트된 파이프라인 구성을 반환합니다.
/// </summary>
/// <param name="responseMask">HTTP response에 적용할 <see cref="DataMask"/>입니다.</param>
/// <param name="responseMask">HTTP response에 적용할 <see cref="HttpResponseMask"/>입니다.</param>
/// <returns>지정된 response 마스크가 적용된 업데이트된 <see cref="HttpPipeLineInfo"/> 인스턴스입니다.</returns>
public HttpPipeLineInfo setResponseMask(ResponseMask responseMask)
public HttpPipeLineInfo setResponseMask(HttpResponseMask responseMask)
{
_responseMask = responseMask;
return this;
@@ -146,13 +154,25 @@ namespace UVC.Data
/// <summary>
/// HTTP 요청이 완료된 후 호출될 핸들러를 설정합니다.
/// 변경 된 데이터는 IDataObject로 전달됩니다.
/// 변경 된 데이터가 없으면 호출 되지 않습니다.
/// UpdatedDataOnly=true일 경우 변경 된 데이터가 없으면 호출 되지 않습니다.
/// </summary>
/// <param name="handler">응답 데이터를 처리할 콜백 함수</param>
/// <returns>현재 HttpPipeLineInfo 인스턴스 (메서드 체이닝용)</returns>
public HttpPipeLineInfo setHandler(Action<IDataObject?>? handler)
public HttpPipeLineInfo setSuccessHandler(Action<IDataObject?>? handler)
{
_handler = handler;
_successhandler = handler;
return this;
}
/// <summary>
/// HTTP 요청이 실패했을 때 호출될 핸들러를 설정합니다.
/// 실패 정보 또는 관련 데이터가 string 형태로 전달될 수 있습니다.
/// </summary>
/// <param name="handler">실패 정보를 처리할 콜백 함수입니다. 실패 시 관련 데이터를 인자로 받습니다.</param>
/// <returns>현재 HttpPipeLineInfo 인스턴스 (메서드 체이닝용)</returns>
public HttpPipeLineInfo setFailHandler(Action<string>? handler)
{
_failhandler = handler;
return this;
}
@@ -204,41 +224,4 @@ namespace UVC.Data
}
}
public class ResponseMask
{
private string successKey = "message";
private string successValue = "success";
private string dataKey = "data";
public string SuccessKey { get; }
public string SuccessValue { get; }
public string DataKey { get; }
public ResponseMask() { }
public ResponseMask(string successKey, string successValue, string dataKey)
{
this.successKey = successKey;
this.successValue = successValue;
this.dataKey = dataKey;
}
public string? Apply(string response)
{
JObject responseObject = JObject.Parse(response);
if(responseObject.TryGetValue(successKey, out JToken? successToken) &&
responseObject.TryGetValue(dataKey, out JToken? dataToken) &&
successToken?.ToString().ToLower() == successValue)
{
// 성공적인 응답 처리 로직
return dataToken?.ToString(); // 실제로는 dataKey에 해당하는 데이터만 반환할 수 있습니다.
}
else
{
return null; // 실패한 응답 처리 로직
}
}
}
}

View File

@@ -0,0 +1,88 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace UVC.Data
{
/// <summary>
/// HTTP 응답 문자열을 파싱하여 성공 여부를 확인하고 실제 데이터를 추출하는 규칙을 정의하는 클래스입니다.
/// </summary>
/// <remarks>
/// 이 클래스는 주로 JSON 형식의 응답에서 특정 키 값을 기준으로 작업의 성공 여부를 판단하고,
/// 성공한 경우 데이터가 담긴 부분을 추출하는 데 사용됩니다.
/// </remarks>
public class HttpResponseMask
{
/// <summary>
/// 응답에서 성공 여부를 나타내는 상태 메시지의 키 이름입니다. 기본값은 "message"입니다.
/// </summary>
private string successKey = "message";
/// <summary>
/// 응답에서 성공 상태를 나타내는 특정 값입니다. 이 값과 <see cref="successKey"/>의 값이 일치하면 성공으로 간주합니다. 기본값은 "success"입니다.
/// </summary>
private string successValue = "success";
/// <summary>
/// 응답에서 실제 데이터 페이로드를 담고 있는 키 이름입니다. 기본값은 "data"입니다.
/// </summary>
private string dataKey = "data";
/// <summary>
/// 응답에서 성공 여부를 나타내는 상태 메시지의 키 이름을 가져옵니다.
/// </summary>
public string SuccessKey => successKey;
/// <summary>
/// 응답에서 성공 상태를 나타내는 특정 값을 가져옵니다.
/// </summary>
public string SuccessValue => successValue;
/// <summary>
/// 응답에서 실제 데이터 페이로드를 담고 있는 키 이름을 가져옵니다.
/// </summary>
public string DataKey => dataKey;
/// <summary>
/// 기본값("message", "success", "data")으로 <see cref="HttpResponseMask"/> 클래스의 새 인스턴스를 초기화합니다.
/// </summary>
public HttpResponseMask() { }
/// <summary>
/// 지정된 키 값으로 <see cref="HttpResponseMask"/> 클래스의 새 인스턴스를 초기화합니다.
/// </summary>
/// <param name="successKey">성공 여부 판단에 사용될 키 이름입니다.</param>
/// <param name="successValue">성공 상태를 나타내는 값입니다.</param>
/// <param name="dataKey">실제 데이터가 포함된 키 이름입니다.</param>
public HttpResponseMask(string successKey, string successValue, string dataKey)
{
this.successKey = successKey;
this.successValue = successValue;
this.dataKey = dataKey;
}
/// <summary>
/// 제공된 HTTP 응답 문자열에 마스크 규칙을 적용하여 파싱 결과를 반환합니다.
/// </summary>
/// <param name="response">파싱할 HTTP 응답 문자열입니다. JSON 형식이어야 합니다.</param>
/// <returns>
/// 파싱 결과를 담고 있는 <see cref="HttpResponseResult"/> 객체입니다.
/// <see cref="HttpResponseResult.IsSuccess"/>가 <c>true</c>이면, <see cref="HttpResponseResult.Data"/>에 추출된 데이터 문자열이 포함됩니다.
/// <see cref="HttpResponseResult.IsSuccess"/>가 <c>false</c>이면, <see cref="HttpResponseResult.Message"/>에 원본 응답 문자열이 포함될 수 있습니다.
/// </returns>
/// <remarks>
/// 이 메서드는 먼저 응답 문자열을 <see cref="JObject"/>로 파싱합니다.
/// 그 다음, <see cref="SuccessKey"/>에 해당하는 토큰 값과 <see cref="SuccessValue"/>를 비교하여 (대소문자 구분 없이) 성공 여부를 판단합니다.
/// 성공한 경우, <see cref="DataKey"/>에 해당하는 토큰의 문자열 값을 <see cref="HttpResponseResult.Data"/>에 설정하고 <see cref="HttpResponseResult.IsSuccess"/>를 <c>true</c>로 설정합니다.
/// 만약 <see cref="SuccessKey"/>에 해당하는 토큰 값과 <see cref="SuccessValue"/>를 비교하여 같지만 <see cref="DataKey"/>에 해당하는 토큰이 없거나 null인 경우에도 <see cref="HttpResponseResult.IsSuccess"/>를 <c>false</c>로 설정합니다.
/// 실패한 경우, 원본 응답 문자열을 <see cref="HttpResponseResult.Message"/>에 설정하고 <see cref="HttpResponseResult.IsSuccess"/>를 <c>false</c>로 설정합니다.
/// </remarks>
public HttpResponseResult Apply(string response)
{
JObject responseObject = JObject.Parse(response);
if (responseObject.TryGetValue(successKey, out JToken? successToken) &&
responseObject.TryGetValue(dataKey, out JToken? dataToken) &&
successToken?.ToString().ToLower() == successValue.ToLower()) // successValue 비교 시에도 ToLower() 추가하여 일관성 유지
{
// 성공적인 응답 처리 로직
if (dataToken != null) return new HttpResponseResult(true, dataToken!.ToString(Formatting.None)); // 실제로는 dataKey에 해당하는 데이터만 반환할 수 있습니다.
}
return new HttpResponseResult(false, null, response); // 실패한 응답 처리 로직
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 28af8b9febad2954a93c6bebda8d62ee

View File

@@ -0,0 +1,36 @@
namespace UVC.Data
{
/// <summary>
/// HttpResponseMask.Apply 메서드의 결과를 나타내는 클래스입니다.
/// </summary>
public class HttpResponseResult
{
/// <summary>
/// 응답이 성공했는지 여부를 나타냅니다.
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// 성공적으로 파싱된 경우, 추출된 데이터의 문자열 표현입니다.
/// <see cref="IsSuccess"/>가 <c>false</c>인 경우 이 값은 <c>null</c> 입니다.
/// </summary>
public string? Data { get; set; }
/// <summary>
/// 파싱에 실패한 경우, 원본 응답 메시지 또는 오류 메시지를 포함합니다.
/// <see cref="IsSuccess"/>가 <c>true</c>인 경우 이 값은 일반적으로 <c>null</c>입니다.
/// </summary>
public string? Message { get; set; }
/// <summary>
/// <see cref="HttpResponseResult"/> 클래스의 새 인스턴스를 초기화합니다.
/// </summary>
/// <param name="isSuccess">파싱 성공 여부입니다.</param>
/// <param name="data">추출된 데이터입니다. 성공하지 못한 경우 <c>null</c>일 수 있습니다.</param>
/// <param name="message">실패 시 메시지입니다. 성공한 경우 <c>null</c>일 수 있습니다.</param>
public HttpResponseResult(bool isSuccess, string? data, string? message = null)
{
IsSuccess = isSuccess;
Data = data;
Message = message;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 52872327cdcef7540be0704fa181c778

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9beacd432e8d2a940943f6d1452747ec
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,49 @@
using Newtonsoft.Json;
using System.Collections.Generic;
namespace UVC.Locale
{
/// <summary>
/// JSON 형식의 다국어 데이터를 담는 클래스입니다.
/// 이 클래스는 로드된 JSON 파일의 전체 구조에 해당합니다.
/// </summary>
/// <example>
/// JSON 파일 예시 (`locale.json`):
/// <code>
/// {
/// "en-US": {
/// "welcome_message": "Welcome!",
/// "greeting": "Hello, {0}!"
/// },
/// "ko-KR": {
/// "welcome_message": "환영합니다!",
/// "greeting": "안녕하세요, {0}님!"
/// }
/// }
/// </code>
/// </example>
public class LocalizationDataSource
{
/// <summary>
/// 다국어 번역 데이터를 저장하는 딕셔너리입니다.
/// 키는 언어 코드(예: "en-US", "ko-KR")이며,
/// 값은 해당 언어의 번역 문자열 키와 번역된 텍스트를 담는 또 다른 딕셔너리입니다.
/// </summary>
/// <example>
/// <code>
/// // "en-US" 언어의 "welcome_message" 키에 해당하는 값은 "Welcome!" 입니다.
/// // Translations["en-US"]["welcome_message"] -> "Welcome!"
/// </code>
/// </example>
public Dictionary<string, Dictionary<string, string>> Translations { get; set; }
/// <summary>
/// <see cref="LocalizationDataSource"/> 클래스의 새 인스턴스를 초기화합니다.
/// <see cref="Translations"/> 딕셔너리를 빈 상태로 생성합니다.
/// </summary>
public LocalizationDataSource()
{
Translations = new Dictionary<string, Dictionary<string, string>>();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e2461a6116e68f54985c3dc4f5b08574

View File

@@ -0,0 +1,301 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UVC.Json;
using UVC.Log;
namespace UVC.Locale
{
/// <summary>
/// 다국어 데이터를 관리하고, 현재 설정된 언어에 맞는 번역 문자열을 제공하는 클래스입니다.
/// JSON 파일을 로드하여 다국어 데이터를 초기화하며, 언어 변경 시 이벤트를 통해 알림을 제공합니다.
/// 이 클래스는 싱글톤으로 구현되어 어디서든 <see cref="Instance"/>를 통해 접근할 수 있습니다.
/// </summary>
/// <example>
/// <code>
/// // 1. LocalizationManager 인스턴스 접근 (싱글톤)
/// // LocalizationManager locManager = LocalizationManager.Instance; // 직접 인스턴스화 대신 Instance 사용
///
/// // 2. (선택 사항) 언어 변경 이벤트 구독
/// LocalizationManager.Instance.OnLanguageChanged += (newLanguage) => {
/// ULog.Debug($"언어가 {newLanguage}(으)로 변경되었습니다.");
/// // UI 업데이트 로직 등
/// };
///
/// // 3. 다국어 데이터 로드 (StreamingAssets 폴더의 locale.json 파일을 기본으로 로드)
/// // 인스턴스 생성 시 자동으로 로드하거나, 별도의 초기화 메서드를 통해 로드할 수 있습니다.
/// // 예시: 로드 메서드가 private 생성자 또는 별도 초기화 메서드에서 호출된다고 가정
/// if (LocalizationManager.Instance.LoadDefaultLocalizationData()) // LoadDefaultLocalizationData가 public이라고 가정
/// {
/// ULog.Debug("다국어 데이터 로드 성공!");
///
/// // 4. 사용 가능한 언어 목록 확인
/// List<string> availableLangs = LocalizationManager.Instance.AvailableLanguages;
/// ULog.Debug("사용 가능한 언어: " + string.Join(", ", availableLangs));
///
/// // 5. 언어 변경
/// LocalizationManager.Instance.SetCurrentLanguage("ko-KR"); // 한국어로 변경
///
/// // 6. 번역된 문자열 가져오기
/// string welcomeMsg = LocalizationManager.Instance.GetString("welcome_message");
/// ULog.Debug(welcomeMsg); // 출력 (한국어): 환영합니다!
///
/// LocalizationManager.Instance.SetCurrentLanguage("en-US"); // 영어로 변경
/// welcomeMsg = LocalizationManager.Instance.GetString("welcome_message");
/// ULog.Debug(welcomeMsg); // 출력 (영어): Welcome!
/// }
/// else
/// {
/// ULog.Error("다국어 데이터 로드 실패.");
/// }
/// </code>
/// </example>
public class LocalizationManager
{
#region singleton
// 싱글톤 인스턴스를 저장하기 위한 private static 변수
private static LocalizationManager _instance;
// 멀티스레드 환경에서의 동시 접근을 제어하기 위한 lock 객체
private static readonly object _lock = new object();
/// <summary>
/// <see cref="LocalizationManager"/>의 싱글톤 인스턴스를 가져옵니다.
/// 인스턴스가 아직 생성되지 않았다면 새로 생성하여 반환합니다.
/// </summary>
public static LocalizationManager Instance
{
get
{
// Double-checked locking으로 스레드 안전성 확보
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
{
_instance = new LocalizationManager("en-US");
_instance.LoadDefaultLocalizationData(); // 기본 언어 데이터 로드
}
}
}
return _instance;
}
}
#endregion
private LocalizationDataSource _dataSource;
private string _currentLanguage;
/// <summary>
/// 언어가 변경되었을 때 발생하는 이벤트의 델리게이트입니다.
/// </summary>
/// <param name="newLanguageCode">새롭게 설정된 언어 코드입니다 (예: "en-US", "ko-KR").</param>
public delegate void LanguageChangedHandler(string newLanguageCode);
/// <summary>
/// 현재 언어가 <see cref="SetCurrentLanguage"/> 메서드를 통해 성공적으로 변경되었을 때 발생하는 이벤트입니다.
/// </summary>
public event LanguageChangedHandler OnLanguageChanged;
/// <summary>
/// 현재 설정된 언어 코드를 가져옵니다.
/// </summary>
public string CurrentLanguage => _currentLanguage;
/// <summary>
/// 로드된 다국어 데이터에서 사용 가능한 모든 언어 코드 목록을 가져옵니다.
/// 데이터가 로드되지 않았거나 번역 데이터가 비어있으면 빈 리스트를 반환합니다.
/// </summary>
public List<string> AvailableLanguages
{
get
{
if (_dataSource.Translations != null)
{
return new List<string>(_dataSource.Translations.Keys);
}
return new List<string>(); // 데이터가 없을 경우 빈 리스트 반환
}
}
/// <summary>
/// <see cref="LocalizationManager"/> 클래스의 새 인스턴스를 초기화합니다.
/// 기본 언어를 "ko-KR"로 설정합니다.
/// 생성자를 private으로 변경하여 외부에서의 직접적인 인스턴스 생성을 막습니다.
/// </summary>
private LocalizationManager(string defaultLanguage = "ko-KR")
{
_dataSource = new LocalizationDataSource();
// 기본 언어를 설정하거나, 시스템 설정을 따르도록 초기화할 수 있습니다.
_currentLanguage = defaultLanguage; // 예시 기본 언어
}
/// <summary>
/// 기본 경로(<see cref="Application.streamingAssetsPath"/>/locale.json)에서 다국어 데이터를 로드합니다.
/// </summary>
/// <returns>데이터 로드 성공 시 true, 실패 시 false를 반환합니다.</returns>
public bool LoadDefaultLocalizationData()
{
string path = Path.Combine(Application.streamingAssetsPath, "locale.json");
return LoadLocalizationData(path);
}
/// <summary>
/// 지정된 파일 경로에서 JSON 형식의 다국어 데이터를 로드합니다.
/// </summary>
/// <param name="filePath">로드할 JSON 파일의 전체 경로입니다.</param>
/// <returns>데이터 로드 및 분석 성공 시 true, 실패 시 false를 반환합니다.</returns>
public bool LoadLocalizationData(string filePath)
{
if (!File.Exists(filePath))
{
ULog.Error($"Localization file not found: {filePath}", new System.Exception($"Localization file not found: {filePath}"));
return false;
}
try
{
string jsonData = File.ReadAllText(filePath);
// 직접 Dictionary로 역직렬화한 후 Translations에 할당
var translationsData = JsonHelper.FromJson<Dictionary<string, Dictionary<string, string>>>(jsonData);
if (translationsData == null)
{
ULog.Error($"Failed to deserialize localization data or no translations found in: {filePath}");
_dataSource = new LocalizationDataSource(); // 빈 데이터 소스로 초기화
return false;
}
_dataSource.Translations = translationsData; // 읽어온 데이터를 Translations 프로퍼티에 할당
// 데이터 로드 후, 현재 설정된 언어가 유효한지 확인하고,
// 유효하지 않다면 사용 가능한 첫 번째 언어나 기본 언어로 설정할 수 있습니다.
if (!string.IsNullOrEmpty(_currentLanguage) && !_dataSource.Translations.ContainsKey(_currentLanguage))
{
ULog.Warning($"Current language '{_currentLanguage}' not found in the new data source. Attempting to set to default or first available.");
if (AvailableLanguages.Count > 0)
{
SetCurrentLanguage(AvailableLanguages[0]); // 사용 가능한 첫 번째 언어로 설정
}
// else: 사용 가능한 언어가 없으면 변경하지 않음 (또는 특정 기본값으로 설정)
}
return true;
}
catch (Exception ex)
{
ULog.Error($"Error loading localization data from {filePath}: {ex.Message}", ex);
_dataSource = new LocalizationDataSource(); // 오류 발생 시 빈 데이터 소스로 초기화
return false;
}
}
/// <summary>
/// 현재 사용 언어를 변경합니다.
/// 성공적으로 언어가 변경되면 <see cref="OnLanguageChanged"/> 이벤트가 발생합니다.
/// </summary>
/// <param name="languageCode">설정할 언어 코드입니다 (예: "en-US", "ko-KR").</param>
public void SetCurrentLanguage(string languageCode)
{
if (string.IsNullOrEmpty(languageCode))
{
ULog.Warning("Language code cannot be null or empty.");
return;
}
if (_dataSource?.Translations == null)
{
ULog.Warning("Translations data source is not loaded. Cannot set language.");
return;
}
if (_dataSource.Translations.ContainsKey(languageCode))
{
if (_currentLanguage != languageCode)
{
_currentLanguage = languageCode;
OnLanguageChanged?.Invoke(_currentLanguage); // 언어가 성공적으로 변경되면 이벤트 발생
ULog.Debug($"Current language changed to: {languageCode}");
}
}
else
{
ULog.Warning($"Language '{languageCode}' not found in data source. Current language remains '{_currentLanguage}'.");
}
}
/// <summary>
/// 현재 설정된 언어를 기준으로, 주어진 키에 해당하는 번역된 문자열을 가져옵니다.
/// </summary>
/// <param name="key">번역 문자열을 찾기 위한 키입니다.</param>
/// <returns>
/// 번역된 문자열을 반환합니다.
/// 키가 null이거나 비어있으면 빈 문자열을 반환합니다.
/// 해당 키 또는 현재 언어에 대한 번역을 찾을 수 없으면, "[키]" 형식의 문자열을 반환합니다.
/// </returns>
public string GetString(string key)
{
if (string.IsNullOrEmpty(key))
{
ULog.Warning("Requested localization key is null or empty.");
return string.Empty;
}
if (_dataSource?.Translations == null)
{
ULog.Warning("Translations data source is not loaded. Cannot get string for key: " + key);
return $"[{key}]";
}
if (_dataSource.Translations.TryGetValue(_currentLanguage, out var languageSpecificStrings) &&
languageSpecificStrings.TryGetValue(key, out var translatedString))
{
return translatedString;
}
ULog.Debug($"Translation for key '{key}' not found in language '{_currentLanguage}'.");
return $"[{key}]"; // 키를 그대로 반환하는 예시
}
/// <summary>
/// 특정 언어의 특정 키에 대한 번역된 문자열을 직접 가져옵니다.
/// </summary>
/// <param name="languageCode">문자열을 가져올 대상 언어 코드입니다.</param>
/// <param name="key">번역 문자열을 찾기 위한 키입니다.</param>
/// <returns>
/// 번역된 문자열을 반환합니다.
/// 언어 코드나 키가 null이거나 비어있으면 빈 문자열을 반환합니다.
/// 해당 언어 또는 키에 대한 번역을 찾을 수 없으면 null을 반환합니다.
/// </returns>
public string? GetString(string languageCode, string key)
{
if (string.IsNullOrEmpty(languageCode))
{
ULog.Warning("Requested language code is null or empty for GetString(languageCode, key).");
return null;
}
if (string.IsNullOrEmpty(key))
{
ULog.Warning("Requested localization key is null or empty for GetString(languageCode, key).");
return null;
}
if (_dataSource?.Translations == null)
{
ULog.Warning("Translations data source is not loaded. Cannot get string for key: " + key + " in language: " + languageCode);
return null;
}
if (_dataSource.Translations.TryGetValue(languageCode, out var languageSpecificStrings) &&
languageSpecificStrings.TryGetValue(key, out var translatedString))
{
return translatedString;
}
ULog.Debug($"Translation for key '{key}' not found in language '{languageCode}'.");
return null; // 해당 언어/키가 없음을 표시 (이전에는 "[languageCode:key]" 반환)
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9f617c21b60c55f42ace7c3a88f81a32

View File

@@ -1,4 +1,6 @@
using log4net;
#nullable enable
using log4net;
using log4net.Appender;
using log4net.Core;
using log4net.Filter;
@@ -26,6 +28,7 @@ namespace UVC.Log
{
log4net.Config.XmlConfigurator.Configure(new System.IO.FileInfo(Path.Combine(Application.dataPath, @"Resources\log4net.runtime.xml")));
}
// unity editor 일때
else
{
log4net.Config.XmlConfigurator.Configure(new System.IO.FileInfo(Path.Combine(Application.dataPath, @"Resources\log4net.editor.xml")));
@@ -78,13 +81,13 @@ namespace UVC.Log
}
public static void Warning(string msg, Exception ex)
public static void Warning(string msg, Exception? ex = null)
{
if (logger.IsWarnEnabled)
{
if (useUnityDebug)
{
UnityEngine.Debug.LogWarning(msg + ", " + ex);
UnityEngine.Debug.LogWarning(msg + (ex != null ? ", " + ex: ""));
}
else
{
@@ -99,13 +102,13 @@ namespace UVC.Log
}
}
public static void Error(string msg, Exception ex)
public static void Error(string msg, Exception? ex = null)
{
if (logger.IsErrorEnabled)
{
if (useUnityDebug)
{
UnityEngine.Debug.LogError(msg + ", " + ex);
UnityEngine.Debug.LogError(msg + (ex != null ? ", " + ex : ""));
}
else
{
@@ -120,13 +123,13 @@ namespace UVC.Log
}
}
public static void Fatal(string msg, Exception ex)
public static void Fatal(string msg, Exception? ex = null)
{
if (logger.IsFatalEnabled)
{
if (useUnityDebug)
{
UnityEngine.Debug.LogError(msg + ", " + ex);
UnityEngine.Debug.LogError(msg + (ex != null ? ", " + ex : ""));
}
else
{

View File

@@ -495,12 +495,12 @@ namespace UVC.Network
}
else
{
ULog.Debug($"Server error({resp.StatusCode} - {resp.Message})! " + new Exception(resp.Message));
ULog.Warning($"Server error({resp.StatusCode} - {resp.Message})! ");
OnError?.Invoke($"Server error({resp.StatusCode} - {resp.Message})!");
}
break;
default:
ULog.Debug(req.State.ToString() + ", " + new Exception(resp.Message));
ULog.Error(req.State.ToString(), new Exception(resp.Message));
OnError?.Invoke(req.State.ToString());
break;
}

View File

@@ -305,7 +305,7 @@ namespace UVC.network
/// </remarks>
private void OnErrorMQTT(MQTTClient client, string reason)
{
ULog.Error($"MQTT OnError reason: '{reason}'", new Exception(reason));
ULog.Error($"MQTT OnError reason: '{reason}'");
}
/// <summary>
@@ -359,7 +359,7 @@ namespace UVC.network
{
if (client == null || client.State != ClientStates.Connected)
{
ULog.Error("MQTT client is not connected. Cannot publish message.", new Exception("MQTT client is not connected. Cannot publish message."));
ULog.Error("MQTT client is not connected. Cannot publish message.");
return;
}
client.CreateApplicationMessageBuilder(topic)

View File

@@ -4,10 +4,8 @@ using Cysharp.Threading.Tasks;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UVC.Data;
@@ -46,22 +44,33 @@ namespace UVC.Tests.Data
Setup();
Debug.Log("===== HttpPipeLine 테스트 시작 =====");
RunTest(nameof(Add_NewInfo_AddedSuccessfully), Add_NewInfo_AddedSuccessfully);
RunTest(nameof(Add_ExistingInfo_UpdatesExistingEntry), Add_ExistingInfo_UpdatesExistingEntry);
await RunTestAsync(nameof(Remove_ExistingInfo_RemovedSuccessfullyAsync), Remove_ExistingInfo_RemovedSuccessfullyAsync);
await RunTestAsync(nameof(Remove_NonExistingInfo_DoesNothing), Remove_NonExistingInfo_DoesNothing);
RunTest(nameof(Excute_WithNonExistingKey_DoesNothing), Excute_WithNonExistingKey_DoesNothing);
await RunTestAsync(nameof(Excute_WithJObjectResponse_ProcessesDataCorrectly), Excute_WithJObjectResponse_ProcessesDataCorrectly);
await RunTestAsync(nameof(Excute_WithJArrayResponse_ProcessesDataCorrectly), Excute_WithJArrayResponse_ProcessesDataCorrectly);
await RunTestAsync(nameof(Test_Excute_AgvDataParsing), Test_Excute_AgvDataParsing);
await RunTestAsync(nameof(Test_Excute_AlarmDataParsing), Test_Excute_AlarmDataParsing);
await RunTestAsync(nameof(Test_Excute_MultipleDataTypes), Test_Excute_MultipleDataTypes);
await RunTestAsync(nameof(Test_Excute_CarrierDataParsing), Test_Excute_CarrierDataParsing);
await RunTestAsync(nameof(Test_Excute_BaseInfoDataParsing), Test_Excute_BaseInfoDataParsing);
await RunTestAsync(nameof(Test_Excute_WithRepeatExecution), Test_Excute_WithRepeatExecution);
await RunTestAsync(nameof(Test_StopRepeat_StopsExecutionCorrectly), Test_StopRepeat_StopsExecutionCorrectly);
await RunTestAsync(nameof(Test_MultipleRepeatingRequests_ManagedIndependently), Test_MultipleRepeatingRequests_ManagedIndependently);
await RunTestAsync(nameof(Test_RepeatWithCount_StopsAutomatically), Test_RepeatWithCount_StopsAutomatically);
//RunTest(nameof(Add_NewInfo_AddedSuccessfully), Add_NewInfo_AddedSuccessfully);
//RunTest(nameof(Add_ExistingInfo_UpdatesExistingEntry), Add_ExistingInfo_UpdatesExistingEntry);
//await RunTestAsync(nameof(Remove_ExistingInfo_RemovedSuccessfullyAsync), Remove_ExistingInfo_RemovedSuccessfullyAsync);
//await RunTestAsync(nameof(Remove_NonExistingInfo_DoesNothing), Remove_NonExistingInfo_DoesNothing);
//RunTest(nameof(Excute_WithNonExistingKey_DoesNothing), Excute_WithNonExistingKey_DoesNothing);
//await RunTestAsync(nameof(Excute_WithJObjectResponse_ProcessesDataCorrectly), Excute_WithJObjectResponse_ProcessesDataCorrectly);
//await RunTestAsync(nameof(Excute_WithJArrayResponse_ProcessesDataCorrectly), Excute_WithJArrayResponse_ProcessesDataCorrectly);
//await RunTestAsync(nameof(Test_Excute_AgvDataParsing), Test_Excute_AgvDataParsing);
//await RunTestAsync(nameof(Test_Excute_AlarmDataParsing), Test_Excute_AlarmDataParsing);
//await RunTestAsync(nameof(Test_Excute_MultipleDataTypes), Test_Excute_MultipleDataTypes);
//await RunTestAsync(nameof(Test_Excute_CarrierDataParsing), Test_Excute_CarrierDataParsing);
//await RunTestAsync(nameof(Test_Excute_BaseInfoDataParsing), Test_Excute_BaseInfoDataParsing);
//await RunTestAsync(nameof(Test_Excute_WithRepeatExecution), Test_Excute_WithRepeatExecution);
//await RunTestAsync(nameof(Test_StopRepeat_StopsExecutionCorrectly), Test_StopRepeat_StopsExecutionCorrectly);
//await RunTestAsync(nameof(Test_MultipleRepeatingRequests_ManagedIndependently), Test_MultipleRepeatingRequests_ManagedIndependently);
//await RunTestAsync(nameof(Test_RepeatWithCount_StopsAutomatically), Test_RepeatWithCount_StopsAutomatically);
// HttpResponseMask 테스트 추가
Debug.Log("===== HttpResponseMask 테스트 시작 =====");
RunTest(nameof(HttpResponseMask_Apply_SuccessfulResponse_ReturnsSuccessWithData), HttpResponseMask_Apply_SuccessfulResponse_ReturnsSuccessWithData);
RunTest(nameof(HttpResponseMask_Apply_FailedResponse_WrongSuccessValue_ReturnsFailWithMessage), HttpResponseMask_Apply_FailedResponse_WrongSuccessValue_ReturnsFailWithMessage);
RunTest(nameof(HttpResponseMask_Apply_FailedResponse_MissingSuccessKey_ReturnsFailWithMessage), HttpResponseMask_Apply_FailedResponse_MissingSuccessKey_ReturnsFailWithMessage);
RunTest(nameof(HttpResponseMask_Apply_FailedResponse_MissingDataKey_ReturnsFailWithMessage), HttpResponseMask_Apply_FailedResponse_MissingDataKey_ReturnsFailWithMessage);
RunTest(nameof(HttpResponseMask_Apply_SuccessfulResponse_CustomKeys_ReturnsSuccessWithData), HttpResponseMask_Apply_SuccessfulResponse_CustomKeys_ReturnsSuccessWithData);
RunTest(nameof(HttpResponseMask_Apply_InvalidJson_ThrowsException), HttpResponseMask_Apply_InvalidJson_ThrowsException);
Debug.Log("===== HttpResponseMask 테스트 완료 =====");
Debug.Log("===== HttpPipeLine 테스트 완료 =====");
}
@@ -234,7 +243,7 @@ namespace UVC.Tests.Data
// HttpPipeLineInfo 설정
var info = new HttpPipeLineInfo("http://test.com")
.setDataMapper(dataMapper)
.setHandler((data) =>
.setSuccessHandler((data) =>
{
handlerCalled = true;
if (data is DataObject dataObject)
@@ -291,7 +300,7 @@ namespace UVC.Tests.Data
// HttpPipeLineInfo 설정
var info = new HttpPipeLineInfo("http://test.com")
.setDataMapper(dataMapper)
.setHandler((data) =>
.setSuccessHandler((data) =>
{
handlerCalled = true;
receivedData = data;
@@ -337,7 +346,7 @@ namespace UVC.Tests.Data
// Arrange - 의도적으로 아무 것도 설정하지 않음
// Act & Assert - 예외가 발생하지 않아야 함
Assert.DoesNotThrow(() => pipeLine.Excute("nonExistingKey"));
Assert.DoesNotThrow(() => pipeLine.Excute("nonExistingKey"));
}
/// <summary>
@@ -368,7 +377,7 @@ namespace UVC.Tests.Data
// HttpPipeLineInfo 설정
var info = new HttpPipeLineInfo(agvUrl, "get")
.setDataMapper(dataMapper)
.setHandler((data) =>
.setSuccessHandler((data) =>
{
handlerCalled = true;
receivedData = data;
@@ -422,7 +431,7 @@ namespace UVC.Tests.Data
// HttpPipeLineInfo 설정
var info = new HttpPipeLineInfo(alarmUrl, "get")
.setDataMapper(dataMapper)
.setHandler((data) =>
.setSuccessHandler((data) =>
{
handlerCalled = true;
receivedData = data;
@@ -505,7 +514,7 @@ namespace UVC.Tests.Data
string key = item.Key;
var info = new HttpPipeLineInfo(item.Value, "get")
.setDataMapper(new DataMapper(dataMasks[key]))
.setHandler((data) =>
.setSuccessHandler((data) =>
{
handlerCallCount++;
results[key] = data;
@@ -544,7 +553,7 @@ namespace UVC.Tests.Data
// DataMask와 DataMapper 설정
var dataMask = new DataMask
{
{
["MAIN_CARR_ID"] = "캐리어ID",
["SUB_CARR_ID"] = "서브ID",
["CARR_SEQ"] = "순번",
@@ -556,7 +565,7 @@ namespace UVC.Tests.Data
// HttpPipeLineInfo 설정
var info = new HttpPipeLineInfo(testUrl, "get")
.setDataMapper(dataMapper)
.setHandler((data) =>
.setSuccessHandler((data) =>
{
handlerCalled = true;
receivedData = data;
@@ -634,7 +643,7 @@ namespace UVC.Tests.Data
// HttpPipeLineInfo 설정
var info = new HttpPipeLineInfo(baseInfoUrl, "get")
.setDataMapper(dataMapper)
.setHandler((data) =>
.setSuccessHandler((data) =>
{
handlerCalled = true;
receivedData = data;
@@ -720,7 +729,7 @@ namespace UVC.Tests.Data
// 반복 실행 설정을 포함한 HttpPipeLineInfo 생성
var info = new HttpPipeLineInfo(testUrl, "get")
.setDataMapper(dataMapper)
.setHandler(async (data) =>
.setSuccessHandler(async (data) =>
{
handlerCallCount++;
if (data is DataObject dataObject)
@@ -794,7 +803,7 @@ namespace UVC.Tests.Data
// 무한 반복 설정을 포함한 HttpPipeLineInfo 생성
var info = new HttpPipeLineInfo(testUrl, "get")
.setDataMapper(dataMapper)
.setHandler((data) => { handlerCallCount++;})
.setSuccessHandler((data) => { handlerCallCount++; })
.setRepeat(true, 0, repeatInterval, false); // 무한 반복 (repeatCount = 0)
pipeLine.UseMockup = true;
@@ -862,12 +871,12 @@ namespace UVC.Tests.Data
// 두 개의 반복 요청 설정
var info1 = new HttpPipeLineInfo(testUrl1, "get")
.setDataMapper(dataMapper)
.setHandler((data) => { handlerCallCount1++; })
.setSuccessHandler((data) => { handlerCallCount1++; })
.setRepeat(true, 0, repeatInterval1, false);
var info2 = new HttpPipeLineInfo(testUrl2, "get")
.setDataMapper(dataMapper)
.setHandler((data) => { handlerCallCount2++; })
.setSuccessHandler((data) => { handlerCallCount2++; })
.setRepeat(true, 0, repeatInterval2, false);
pipeLine.UseMockup = true;
@@ -944,7 +953,7 @@ namespace UVC.Tests.Data
// 반복 횟수가 지정된 HttpPipeLineInfo 생성
var info = new HttpPipeLineInfo(testUrl, "get")
.setDataMapper(dataMapper)
.setHandler((data) =>
.setSuccessHandler((data) =>
{
handlerCallCount++;
receivedData.Add(data);
@@ -996,5 +1005,127 @@ namespace UVC.Tests.Data
return (Dictionary<string, CancellationTokenSource>)fieldInfo.GetValue(pipeLine);
}
#region HttpResponseMask Tests
/// <summary>
/// HttpResponseMask.Apply가 성공적인 응답을 올바르게 처리하는지 테스트합니다.
/// </summary>
[Test]
public void HttpResponseMask_Apply_SuccessfulResponse_ReturnsSuccessWithData()
{
// Arrange
var responseMask = new HttpResponseMask(); // 기본값 사용 (successKey="message", successValue="success", dataKey="data")
var jsonResponse = @"{""message"": ""success"", ""data"": {""key"":""value""}}";
var expectedData = @"{""key"":""value""}";
// Act
var result = responseMask.Apply(jsonResponse);
// Assert
Assert.IsTrue(result.IsSuccess, "결과가 성공이어야 합니다.");
Assert.AreEqual(expectedData, result.Data, "추출된 데이터가 예상과 다릅니다.");
Assert.IsNull(result.Message, "성공 시 메시지는 null이어야 합니다.");
}
/// <summary>
/// HttpResponseMask.Apply가 잘못된 성공 값으로 실패 응답을 올바르게 처리하는지 테스트합니다.
/// </summary>
[Test]
public void HttpResponseMask_Apply_FailedResponse_WrongSuccessValue_ReturnsFailWithMessage()
{
// Arrange
var responseMask = new HttpResponseMask();
var jsonResponse = @"{""message"": ""failed"", ""data"": {""key"": ""value""}}"; // successValue가 "success"가 아님
// Act
var result = responseMask.Apply(jsonResponse);
// Assert
Assert.IsFalse(result.IsSuccess, "결과가 실패여야 합니다.");
Assert.IsNull(result.Data, "실패 시 데이터는 null이어야 합니다.");
Assert.AreEqual(jsonResponse, result.Message, "실패 시 메시지는 원본 응답이어야 합니다.");
}
/// <summary>
/// HttpResponseMask.Apply가 성공 키가 없는 실패 응답을 올바르게 처리하는지 테스트합니다.
/// </summary>
[Test]
public void HttpResponseMask_Apply_FailedResponse_MissingSuccessKey_ReturnsFailWithMessage()
{
// Arrange
var responseMask = new HttpResponseMask();
var jsonResponse = @"{""error"": ""some error"", ""data"": {""key"": ""value""}}"; // "message" 키가 없음
// Act
var result = responseMask.Apply(jsonResponse);
// Assert
Assert.IsFalse(result.IsSuccess, "결과가 실패여야 합니다.");
Assert.IsNull(result.Data, "실패 시 데이터는 null이어야 합니다.");
Assert.AreEqual(jsonResponse, result.Message, "실패 시 메시지는 원본 응답이어야 합니다.");
}
/// <summary>
/// HttpResponseMask.Apply가 데이터 키가 없는 실패 응답을 올바르게 처리하는지 테스트합니다.
/// (현재 구현상 successKey 조건만 만족하면 dataKey가 없어도 성공으로 간주하고 Data를 null로 반환할 수 있으므로, 이 테스트는 실패할 수 있습니다.
/// 요구사항에 따라 이 부분의 동작을 명확히 하고 테스트를 조정해야 합니다.)
/// </summary>
[Test]
public void HttpResponseMask_Apply_FailedResponse_MissingDataKey_ReturnsFailWithMessage()
{
// Arrange
var responseMask = new HttpResponseMask();
// 성공 키는 있지만 데이터 키가 없는 경우
var jsonResponse = @"{""message"": ""success"", ""payload"": {""key"": ""value""}}"; // "data" 키가 없음
// Act
var result = responseMask.Apply(jsonResponse);
// Assert
// 현재 Apply 메서드 구현에 따르면, successKey/successValue가 일치하고 dataKey가 없으면
// IsSuccess = false, Data = null 로 반환됩니다.
// 만약 dataKey가 필수라면 Apply 메서드 수정 또는 이 테스트의 기대 결과 수정이 필요합니다.
// 여기서는 현재 구현을 기준으로 테스트합니다.
Assert.IsFalse(result.IsSuccess, "data 키가 없으면 success 조건 만족 시 실패여야 합니다 (현재 로직 기준).");
Assert.IsNull(result.Data, "data 키가 없는 경우 Data는 null이어야 합니다.");
Assert.IsNotNull(result.Message, "실패 시 메시지는 null이 아니어야 합니다.");
}
/// <summary>
/// HttpResponseMask.Apply가 사용자 정의 키를 사용하여 성공적인 응답을 올바르게 처리하는지 테스트합니다.
/// </summary>
[Test]
public void HttpResponseMask_Apply_SuccessfulResponse_CustomKeys_ReturnsSuccessWithData()
{
// Arrange
var responseMask = new HttpResponseMask("status", "ok", "payload");
var jsonResponse = @"{""status"": ""ok"", ""payload"": {""info"":""custom data""}}";
var expectedData = @"{""info"":""custom data""}";
// Act
var result = responseMask.Apply(jsonResponse);
// Assert
Assert.IsTrue(result.IsSuccess, "결과가 성공이어야 합니다.");
Assert.AreEqual(expectedData, result.Data, "추출된 데이터가 예상과 다릅니다.");
Assert.IsNull(result.Message, "성공 시 메시지는 null이어야 합니다.");
}
/// <summary>
/// HttpResponseMask.Apply가 잘못된 JSON 형식의 응답을 처리할 때 예외를 발생하는지 테스트합니다.
/// </summary>
[Test]
public void HttpResponseMask_Apply_InvalidJson_ThrowsException()
{
// Arrange
var responseMask = new HttpResponseMask();
var invalidJsonResponse = @"{""message"": ""success"", ""data"": {""key"": ""value"""; // 닫는 중괄호 누락
// Act & Assert
Assert.Throws<Newtonsoft.Json.JsonReaderException>(() => responseMask.Apply(invalidJsonResponse), "잘못된 JSON 형식에 대해 JsonReaderException이 발생해야 합니다.");
}
#endregion
}
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 95083d5099fc38b46bfbcec607ff6b31
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e8a3b1d9e4b79ba44b49f0703f4ff753
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,7 @@
namespace UVC.UI.Commands
{
public interface ICommand
{
void Execute();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9f0eef464dda89d4b9f20e533843b83f

View File

@@ -0,0 +1,62 @@
using System;
using UnityEngine;
using UVC.Locale;
using UVC.Log;
namespace UVC.UI.Commands
{
// 간단한 디버그 로그 출력 커맨드
public class DebugLogCommand : ICommand
{
private readonly string _message;
public DebugLogCommand(string message)
{
_message = message;
}
public void Execute()
{
ULog.Debug(_message);
}
}
// 애플리케이션 종료 커맨드
public class QuitApplicationCommand : ICommand
{
public void Execute()
{
ULog.Debug("애플리케이션을 종료합니다.");
Application.Quit();
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false; // 에디터에서 실행 중일 경우 플레이 모드 종료
#endif
}
}
// 언어 변경 커맨드
public class ChangeLanguageCommand : ICommand
{
private readonly string _languageCode;
private readonly LocalizationManager _localizationManager;
public ChangeLanguageCommand(string languageCode, LocalizationManager localizationManager)
{
_languageCode = languageCode;
_localizationManager = localizationManager;
}
public void Execute()
{
if (_localizationManager != null)
{
_localizationManager.SetCurrentLanguage(_languageCode);
ULog.Debug($"언어가 {_languageCode}(으)로 변경되었습니다. (Command)");
}
else
{
ULog.Error("LocalizationManager가 ChangeLanguageCommand에 전달되지 않았습니다.");
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c05439dd4537f6245a5cb70f56d372c7

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1f04f8d2773c12a41a7cbe1f676a20d2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,131 @@
using UnityEngine;
using UVC.Locale;
using System.Collections.Generic;
using UVC.UI.Commands;
using UVC.Log;
using System;
namespace UVC.UI.Menu
{
public class TopMenuController : MonoBehaviour
{
protected TopMenuView view;
protected TopMenuModel model;
protected LocalizationManager _locManager;
protected virtual void Awake()
{
// 1. 동일한 GameObject에서 TopMenuView 컴포넌트 검색
view = GetComponent<TopMenuView>();
// 2. 동일한 GameObject에 없다면, 자식 GameObject에서 검색
if (view == null)
{
view = GetComponentInChildren<TopMenuView>();
}
}
protected virtual void Start()
{
model = new TopMenuModel();
_locManager = LocalizationManager.Instance;
if (view == null)
{
ULog.Error("TopMenuView가 Inspector에서 할당되지 않았습니다.");
return;
}
if (_locManager == null)
{
ULog.Error("LocalizationManager 인스턴스를 찾을 수 없습니다. 메뉴 텍스트가 올바르게 표시되지 않을 수 있습니다.");
// _locManager가 null이어도 메뉴 구조는 생성될 수 있도록 진행합니다.
// TopMenuView에서 _locManager null 체크를 통해 텍스트 처리를 합니다.
}
InitializeMenuItems();
view.ClearMenuItems();
// menuContainer가 TopMenuView 내부에 public으로 노출되어 있다고 가정합니다.
// 만약 private이라면, TopMenuView에 menuContainer를 전달받는 public 메서드가 필요할 수 있습니다.
view.CreateMenuItems(model.MenuItems, view.MenuContainer);
view.OnMenuItemClicked += HandleMenuItemClicked;
if (_locManager != null)
{
_locManager.OnLanguageChanged += HandleLanguageChanged;
}
}
protected virtual void OnDestroy()
{
if (view != null)
{
view.OnMenuItemClicked -= HandleMenuItemClicked;
}
if (_locManager != null)
{
_locManager.OnLanguageChanged -= HandleLanguageChanged;
}
}
protected virtual void InitializeMenuItems()
{
model.MenuItems.Clear();
model.MenuItems.Add(new MenuItemData("file", "menu_file", subMenuItems: new List<MenuItemData>
{
new MenuItemData("file_new", "menu_file_new", subMenuItems: new List<MenuItemData>
{
new MenuItemData("file_new_project", "menu_file_new_project", new DebugLogCommand("새 프로젝트 선택됨 (Command)")),
new MenuItemData("file_new_file", "menu_file_new_file", new DebugLogCommand("새 파일 선택됨 (Command)"))
}),
new MenuItemData("file_open", "menu_file_open", new DebugLogCommand("파일 열기 선택됨 (Command)")),
MenuItemData.CreateSeparator("file_sep1"), // 구분선 추가
new MenuItemData("file_save", "menu_file_save", command: new DebugLogCommand("저장 선택됨 (Command)") , subMenuItems: new List<MenuItemData> // 저장 메뉴에 Command 추가
{
new MenuItemData("file_save_as", "menu_file_save_as", new DebugLogCommand("다른 이름으로 저장 선택됨 (Command)"))
}),
MenuItemData.CreateSeparator("file_sep2"), // 또 다른 구분선 추가
new MenuItemData("file_exit", "menu_file_exit", new QuitApplicationCommand())
}));
model.MenuItems.Add(new MenuItemData("edit", "menu_edit", subMenuItems: new List<MenuItemData>
{
new MenuItemData("edit_undo", "menu_edit_undo", new DebugLogCommand("실행 취소 선택됨 (Command)")),
new MenuItemData("edit_redo", "menu_edit_redo", new DebugLogCommand("다시 실행 선택됨 (Command)")),
MenuItemData.CreateSeparator("edit_sep1"),
new MenuItemData("preferences", "menu_preferences", new DebugLogCommand("환경설정 선택됨 (Command)"))
}));
if (_locManager != null)
{
model.MenuItems.Add(new MenuItemData("language", "menu_language", subMenuItems: new List<MenuItemData>
{
new MenuItemData("lang_ko", "menu_lang_korean", new ChangeLanguageCommand("ko-KR", _locManager)),
new MenuItemData("lang_en", "menu_lang_english", new ChangeLanguageCommand("en-US", _locManager))
}));
}
else
{
ULog.Warning("LocalizationManager가 null이므로 언어 변경 메뉴를 초기화할 수 없습니다.");
}
}
protected virtual void HandleMenuItemClicked(MenuItemData clickedItemData)
{
if (clickedItemData.IsSeparator) return; // 구분선은 클릭 액션이 없음
ULog.Debug($"메뉴 아이템 클릭됨: {clickedItemData.ItemId} (Key: {clickedItemData.DisplayNameKey})");
clickedItemData.Command?.Execute();
}
protected virtual void HandleLanguageChanged(string newLanguageCode)
{
ULog.Debug($"언어 변경 감지: {newLanguageCode}. 메뉴 텍스트 업데이트 중...");
if (view != null && model != null)
{
view.UpdateAllMenuTexts(model.MenuItems);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: af02edfdfb8a7b847aed5397dd6dc4c9

View File

@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using UVC.UI.Commands;
namespace UVC.UI.Menu
{
// 메뉴 아이템 하나를 나타내는 데이터 구조체 또는 클래스
public class MenuItemData
{
public string ItemId { get; private set; }
public string DisplayNameKey { get; private set; } // 다국어 처리를 위한 키
public ICommand Command { get; private set; } // Action 대신 ICommand 사용
public List<MenuItemData> SubMenuItems { get; private set; } // 하위 메뉴 아이템 목록
public bool IsSeparator { get; private set; } // 구분선 여부 플래그
public MenuItemData(string itemId, string displayNameKey, ICommand command = null, List<MenuItemData> subMenuItems = null, bool isSeparator = false)
{
ItemId = itemId;
DisplayNameKey = displayNameKey;
Command = command;
SubMenuItems = subMenuItems ?? new List<MenuItemData>();
IsSeparator = isSeparator;
}
public void AddSubMenuItem(MenuItemData subItem)
{
if (IsSeparator) return; // 구분선에는 하위 메뉴를 추가할 수 없음
SubMenuItems.Add(subItem);
}
/// <summary>
/// 구분선 역할을 하는 MenuItemData 객체를 생성합니다.
/// </summary>
/// <param name="itemId">구분선의 고유 ID. null일 경우 GUID로 자동 생성됩니다.</param>
/// <returns>구분선 MenuItemData 객체입니다.</returns>
public static MenuItemData CreateSeparator(string itemId = null)
{
return new MenuItemData(itemId ?? $"separator_{Guid.NewGuid()}", string.Empty, null, null, true);
}
}
public class TopMenuModel
{
public List<MenuItemData> MenuItems { get; private set; }
public TopMenuModel()
{
MenuItems = new List<MenuItemData>();
}
// LoadMenuItems 메서드는 Controller에서 직접 데이터를 채움
//public void LoadMenuItems(string jsonString) { }
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 44674fdbeb5fa51409a23e6544757068

View File

@@ -0,0 +1,603 @@
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UVC.Locale;
using UVC.Log; // 다국어 처리 네임스페이스 사용
namespace UVC.UI.Menu
{
public class TopMenuView : MonoBehaviour
{
// 프리팹의 Resources 폴더 내 경로 (예시 경로이므로 실제 프로젝트에 맞게 수정 필요)
private const string MenuItemPrefabPath = "Prefabs/UI/Menu/MenuItem";
private const string SubMenuItemPrefabPath = "Prefabs/UI/Menu/SubMenuItem";
private const string MenuSeparatorPrefabPath = "Prefabs/UI/Menu/MenuSeparator";
private const string MenuContainerName = "TopMenu";
private const string SubMenuContainerName = "SubMenuContainer";
private const string SubMenuArrowName = "SubMenuArrow";
protected GameObject menuItemPrefab;
protected GameObject subMenuItemPrefab;
protected GameObject menuSeparatorPrefab;
protected Transform menuContainer;
protected int menuItemInitX = 0; // 1depth 메뉴 아이템의 초기 X 위치
protected int menuItemInitY = 10; // 1depth 메뉴 아이템의 초기 Y 위치
protected int menuItemWidth = 100;
protected int menuItemHeight = 30;
protected int subMenuItemWidth = 100;
protected int subMenuItemHeight = 30;
protected int menuItemSeparatorWidth = 100;
protected int menuItemSeparatorHeight = 1;
protected int menuItemSeparatorVerticalMargin = 4;
protected int menuItemHorizontalGap = 30;
protected int menuItemVerticalGap = 2; // 사용자가 제공한 컨텍스트 값 사용
protected int menuDepthHorizontalGap = 100 - 5;
protected int menuDepthVerticalGap = 10;
protected int subContainerHorizontalPadding = 4;
protected int subContainerVerticalPadding = 10;
public Transform MenuContainer { get => menuContainer; }
public event Action<MenuItemData> OnMenuItemClicked;
protected Dictionary<string, GameObject> _menuItemObjects = new Dictionary<string, GameObject>();
protected LocalizationManager _locManager;
protected GameObject uiBlockerInstance;
protected bool isAnySubMenuOpen = false;
protected virtual void Awake()
{
_locManager = LocalizationManager.Instance;
if (_locManager == null)
{
ULog.Error("LocalizationManager 인스턴스를 찾을 수 없습니다.", new InvalidOperationException("LocalizationManager 인스턴스를 찾을 수 없습니다."));
}
menuItemPrefab = Resources.Load<GameObject>(MenuItemPrefabPath);
if (menuItemPrefab == null)
{
ULog.Error($"메뉴 아이템 프리팹을 Resources 폴더에서 로드할 수 없습니다. 경로: {MenuItemPrefabPath}", new System.IO.FileNotFoundException($"Prefab not found at Resources/{MenuItemPrefabPath}"));
}
subMenuItemPrefab = Resources.Load<GameObject>(SubMenuItemPrefabPath);
if (subMenuItemPrefab == null)
{
ULog.Error($"서브 메뉴 아이템 프리팹을 Resources 폴더에서 로드할 수 없습니다. 경로: {SubMenuItemPrefabPath}", new System.IO.FileNotFoundException($"Prefab not found at Resources/{SubMenuItemPrefabPath}"));
}
menuSeparatorPrefab = Resources.Load<GameObject>(MenuSeparatorPrefabPath);
if (menuSeparatorPrefab == null)
{
ULog.Error($"메뉴 구분선 프리팹을 Resources 폴더에서 로드할 수 없습니다. 경로: {MenuSeparatorPrefabPath}", new System.IO.FileNotFoundException($"Prefab not found at Resources/{MenuSeparatorPrefabPath}"));
}
Transform containerTransform = transform.Find(MenuContainerName);
if (containerTransform != null)
{
menuContainer = containerTransform;
}
else
{
ULog.Warning($"'{MenuContainerName}'이라는 이름의 자식 GameObject를 찾을 수 없어 현재 Transform을 menuContainer로 사용합니다.");
menuContainer = transform;
}
if (menuContainer == null)
{
ULog.Error("menuContainer를 설정할 수 없습니다.", new InvalidOperationException("menuContainer could not be set."));
}
// UI 블로커 생성 및 설정
CreateUIBlocker();
}
protected virtual void CreateUIBlocker()
{
uiBlockerInstance = new GameObject("TopMenuUIBlocker");
// Canvas를 찾아 그 자식으로 설정합니다. Canvas가 여러 개일 경우, 적절한 Canvas를 찾는 로직이 필요할 수 있습니다.
Canvas canvas = FindFirstObjectByType<Canvas>();
Transform blockerParent = canvas != null ? canvas.transform : transform.parent;
if (blockerParent == null)
{
blockerParent = transform;
ULog.Warning("TopMenuUIBlocker의 부모를 TopMenuView 자신으로 설정합니다. Canvas 하위에 배치하는 것을 권장합니다.");
uiBlockerInstance.transform.SetParent(blockerParent, false);
uiBlockerInstance.transform.SetAsFirstSibling();
}
else
{
uiBlockerInstance.transform.SetParent(blockerParent, false);
// TopMenuView GameObject (또는 menuContainer) 바로 앞에 오도록 sibling index 설정
// menuContainer가 Canvas의 직계 자식이 아닐 수 있으므로, menuContainer의 sibling index를 기준으로 합니다.
if (menuContainer != null && menuContainer.parent == blockerParent)
{
uiBlockerInstance.transform.SetSiblingIndex(menuContainer.GetSiblingIndex());
}
else
{
// menuContainer가 blockerParent의 직계 자식이 아니거나 다른 복잡한 구조일 경우,
// 블로커를 가장 뒤(UI 요소들 중에서는 가장 앞)로 보내는 것이 안전할 수 있습니다.
// 또는, TopMenuView 자체가 Canvas의 자식이라면 그 기준으로 설정합니다.
if (transform.parent == blockerParent)
{
uiBlockerInstance.transform.SetSiblingIndex(transform.GetSiblingIndex());
}
else
{
// 최후의 수단으로 가장 뒤로 보냅니다. (UI상 가장 앞으로)
// 하지만 이렇게 되면 다른 UI를 가릴 수 있으므로 주의해야 합니다.
// uiBlockerInstance.transform.SetAsLastSibling();
// 더 나은 방법은 Canvas 내에서 TopMenu 시스템 전체의 루트 바로 앞에 두는 것입니다.
// 여기서는 TopMenuView의 sibling index를 기준으로 합니다.
uiBlockerInstance.transform.SetSiblingIndex(transform.GetSiblingIndex());
}
}
}
RectTransform blockerRect = uiBlockerInstance.AddComponent<RectTransform>();
blockerRect.anchorMin = Vector2.zero;
blockerRect.anchorMax = Vector2.one;
blockerRect.offsetMin = Vector2.zero;
blockerRect.offsetMax = Vector2.zero;
Image blockerImage = uiBlockerInstance.AddComponent<Image>();
blockerImage.color = new Color(0, 0, 0, 0.001f);
blockerImage.raycastTarget = true;
Button blockerButton = uiBlockerInstance.AddComponent<Button>();
blockerButton.transition = Selectable.Transition.None;
blockerButton.onClick.AddListener(CloseAllOpenSubMenus);
uiBlockerInstance.SetActive(false);
}
public virtual Vector2 CreateMenuItems(List<MenuItemData> items, Transform parentContainer, int depth = 0)
{
if (items == null || parentContainer == null)
{
ULog.Error("메뉴 아이템 생성에 필요한 'items' 또는 'parentContainer'가 누락되었습니다.", new ArgumentNullException(items == null ? "items" : "parentContainer"));
return Vector2.zero;
}
float currentLevelCalculatedWidth = 0;
float currentLevelCalculatedHeight = 0;
if (items.Count == 0 && depth > 0)
{
return Vector2.zero;
}
for (int i = 0; i < items.Count; i++)
{
MenuItemData itemData = items[i];
GameObject instanceToLayout = null;
GameObject prefabToUse = null;
if (itemData.IsSeparator)
{
if (menuSeparatorPrefab == null)
{
ULog.Error("menuSeparatorPrefab이 할당되지 않았습니다. 구분선을 생성할 수 없습니다.", new InvalidOperationException("menuSeparatorPrefab is not loaded."));
continue;
}
GameObject separatorInstance = Instantiate(menuSeparatorPrefab, parentContainer);
separatorInstance.name = $"Separator_{itemData.ItemId}_Depth{depth}";
_menuItemObjects[itemData.ItemId] = separatorInstance;
instanceToLayout = separatorInstance;
}
else
{
if (depth == 0) // 1차 깊이 메뉴
{
prefabToUse = menuItemPrefab;
if (prefabToUse == null)
{
ULog.Error("menuItemPrefab이 할당되지 않았습니다. 1차 메뉴 아이템을 생성할 수 없습니다.", new InvalidOperationException("menuItemPrefab is not loaded."));
continue;
}
}
else // 2차 깊이 이상 하위 메뉴
{
prefabToUse = subMenuItemPrefab;
if (prefabToUse == null)
{
ULog.Error("subMenuItemPrefab이 할당되지 않았습니다. 하위 메뉴 아이템을 생성할 수 없습니다.", new InvalidOperationException("subMenuItemPrefab is not loaded."));
continue;
}
}
GameObject menuItemInstance = Instantiate(prefabToUse, parentContainer);
menuItemInstance.name = $"MenuItem_{itemData.ItemId}_Depth{depth}";
_menuItemObjects[itemData.ItemId] = menuItemInstance;
instanceToLayout = menuItemInstance;
TextMeshProUGUI buttonText = menuItemInstance.GetComponentInChildren<TextMeshProUGUI>(true);
Button button = menuItemInstance.GetComponent<Button>();
if (buttonText != null && !string.IsNullOrEmpty(itemData.DisplayNameKey))
{
if (_locManager != null)
{
buttonText.text = _locManager.GetString(itemData.DisplayNameKey);
}
else
{
ULog.Warning($"LocalizationManager 인스턴스가 없어 메뉴 아이템 텍스트를 키 값으로 설정합니다: {itemData.DisplayNameKey}");
buttonText.text = $"[{itemData.DisplayNameKey}]";
}
}
if (button != null)
{
button.onClick.RemoveAllListeners();
button.onClick.AddListener(() =>
{
OnMenuItemClicked?.Invoke(itemData);
if (itemData.SubMenuItems != null && itemData.SubMenuItems.Count > 0)
{
ToggleSubMenuDisplay(itemData, menuItemInstance);
}
else
{
// 하위 메뉴가 없는 아이템 클릭 시 모든 메뉴 닫기 (선택적)
CloseAllOpenSubMenus();
}
});
}
if (itemData.SubMenuItems != null && itemData.SubMenuItems.Count > 0)
{
Transform subMenuArrowTransform = menuItemInstance.transform.Find(SubMenuArrowName);
if (subMenuArrowTransform != null)
{
subMenuArrowTransform.gameObject.SetActive(true);
}
Transform subMenuContainerTransform = menuItemInstance.transform.Find(SubMenuContainerName);
if (subMenuContainerTransform != null)
{
RectTransform subMenuRect = subMenuContainerTransform.GetComponent<RectTransform>();
if (subMenuRect == null)
{
ULog.Warning($"{SubMenuContainerName} for '{menuItemInstance.name}' is missing RectTransform. Adding one.");
subMenuRect = subMenuContainerTransform.gameObject.AddComponent<RectTransform>();
}
subMenuRect.anchorMin = new Vector2(0, 1);
subMenuRect.anchorMax = new Vector2(0, 1);
subMenuRect.pivot = new Vector2(0, 1);
// 하위 메뉴 위치 조정: 1차 메뉴는 오른쪽, 2차 이상은 아래쪽으로 펼쳐지도록 수정
if (depth == 0)
{
subMenuRect.anchoredPosition = new Vector2(menuDepthHorizontalGap, -menuDepthVerticalGap);
}
else
{
// 2차 이상 메뉴의 하위 메뉴는 부모 아이템의 오른쪽에 표시되도록 anchoredPosition 조정
// 부모 아이템의 너비만큼 이동 (subMenuItemWidth 사용)
subMenuRect.anchoredPosition = new Vector2(subMenuItemWidth, 0);
}
Vector2 subContentSize = CreateMenuItems(itemData.SubMenuItems, subMenuContainerTransform, depth + 1);
float containerWidth = subContentSize.x + (2 * subContainerHorizontalPadding);
float containerHeight = subContentSize.y + (2 * subContainerVerticalPadding);
subMenuRect.sizeDelta = new Vector2(containerWidth, containerHeight);
subMenuContainerTransform.gameObject.SetActive(false);
}
else
{
ULog.Warning($"'{menuItemInstance.name}' 내부에 '{SubMenuContainerName}'를 찾을 수 없습니다. 하위 메뉴가 표시되지 않습니다.");
}
}
else
{
Transform existingArrow = menuItemInstance.transform.Find(SubMenuArrowName);
if (existingArrow != null)
{
existingArrow.gameObject.SetActive(false);
}
}
}
if (instanceToLayout != null)
{
LayoutMenuItem(instanceToLayout, itemData, depth, i, items);
}
// 현재 레벨의 전체 너비와 높이 계산
if (depth == 0) // 최상위 메뉴 (수평)
{
if (i > 0) currentLevelCalculatedWidth += menuItemHorizontalGap;
currentLevelCalculatedWidth += itemData.IsSeparator ? menuItemSeparatorWidth : menuItemWidth;
currentLevelCalculatedHeight = Mathf.Max(currentLevelCalculatedHeight, itemData.IsSeparator ? menuItemSeparatorHeight : menuItemHeight);
}
else // 하위 메뉴 (수직)
{
if (i > 0) currentLevelCalculatedHeight += menuItemVerticalGap;
if (itemData.IsSeparator)
{
currentLevelCalculatedHeight += menuItemSeparatorHeight + (2 * menuItemSeparatorVerticalMargin);
}
else
{
currentLevelCalculatedHeight += subMenuItemHeight; // 하위 메뉴 아이템 높이 사용
}
currentLevelCalculatedWidth = Mathf.Max(currentLevelCalculatedWidth, subMenuItemWidth); // 하위 메뉴 아이템 너비 사용
}
}
return new Vector2(currentLevelCalculatedWidth, currentLevelCalculatedHeight);
}
protected virtual void LayoutMenuItem(GameObject uiElement, MenuItemData itemData, int depth, int itemIndex, List<MenuItemData> siblingItems)
{
RectTransform rectTransform = uiElement.GetComponent<RectTransform>();
if (rectTransform == null)
{
ULog.Error($"GameObject '{uiElement.name}' is missing a RectTransform component.");
return;
}
rectTransform.anchorMin = new Vector2(0, 1);
rectTransform.anchorMax = new Vector2(0, 1);
rectTransform.pivot = new Vector2(0, 1);
float currentX = 0;
float currentY = 0;
float currentItemWidth = (depth == 0) ? menuItemWidth : subMenuItemWidth;
float currentItemHeight = (depth == 0) ? menuItemHeight : subMenuItemHeight;
if (depth == 0) // 1차 깊이 메뉴 (수평 레이아웃)
{
currentX = menuItemInitX;
currentY = -menuItemInitY;
for (int k = 0; k < itemIndex; k++)
{
MenuItemData sibling = siblingItems[k];
if (sibling.IsSeparator)
{
currentX += menuItemSeparatorWidth;
}
else
{
currentX += menuItemWidth; // 1차 메뉴 너비
}
currentX += menuItemHorizontalGap;
}
if (itemData.IsSeparator)
{
rectTransform.sizeDelta = new Vector2(menuItemSeparatorWidth, menuItemSeparatorHeight);
}
else
{
rectTransform.sizeDelta = new Vector2(menuItemWidth, menuItemHeight);
}
}
else // 2차 깊이 이상 하위 메뉴 (수직 레이아웃)
{
currentX = subContainerHorizontalPadding; // 하위 메뉴 컨테이너 내부 패딩 적용
currentY = -subContainerVerticalPadding; // 하위 메뉴 컨테이너 내부 패딩 적용
for (int k = 0; k < itemIndex; k++)
{
MenuItemData sibling = siblingItems[k];
if (sibling.IsSeparator)
{
currentY -= (menuItemSeparatorVerticalMargin + menuItemSeparatorHeight + menuItemSeparatorVerticalMargin);
}
else
{
currentY -= subMenuItemHeight; // 하위 메뉴 아이템 높이
}
currentY -= menuItemVerticalGap;
}
if (itemData.IsSeparator)
{
currentY -= menuItemSeparatorVerticalMargin; // 구분선 위쪽 마진
// 하위 메뉴의 구분선 너비는 subMenuItemWidth를 따르도록 수정
rectTransform.sizeDelta = new Vector2(subMenuItemWidth, menuItemSeparatorHeight);
}
else
{
rectTransform.sizeDelta = new Vector2(subMenuItemWidth, subMenuItemHeight);
}
}
rectTransform.anchoredPosition = new Vector2(currentX, currentY);
}
protected virtual void ToggleSubMenuDisplay(MenuItemData itemData, GameObject menuItemInstance)
{
if (itemData.IsSeparator) return;
Transform subMenuContainer = menuItemInstance.transform.Find("SubMenuContainer");
if (subMenuContainer != null)
{
bool isActive = subMenuContainer.gameObject.activeSelf;
bool becomingActive = !isActive;
if (becomingActive)
{
CloseOtherSubMenusInParent(menuItemInstance.transform.parent, subMenuContainer);
// 하위 메뉴를 포함하는 menuItemInstance를 가장 앞으로 가져와서 다른 1단계 메뉴에 가려지지 않도록 합니다.
// 이는 menuItemInstance가 menuContainer의 직계 자식일 때 (depth 0 메뉴 아이템) 가장 효과적입니다.
// 더 깊은 레벨의 하위 메뉴는 이미 부모 SubMenuContainer 내에 있으므로,
// 해당 SubMenuContainer를 포함하는 상위 레벨의 menuItemInstance가 앞으로 와야 합니다.
// 최상위 메뉴 아이템(depth 0)의 경우, 그 부모는 menuContainer입니다.
// 하위 메뉴 아이템의 경우, 그 부모는 다른 SubMenuContainer입니다.
// 현재 로직은 클릭된 menuItemInstance를 그 부모 컨테이너 내에서 맨 앞으로 가져옵니다.
menuItemInstance.transform.SetAsLastSibling();
}
subMenuContainer.gameObject.SetActive(!isActive);
if (becomingActive)
{
isAnySubMenuOpen = true;
}
else
{
CheckIfAnySubMenuRemainsOpen();
}
UpdateBlockerVisibility();
}
}
protected virtual void CloseOtherSubMenusInParent(Transform currentMenuItemsParent, Transform subMenuToExclude)
{
if (currentMenuItemsParent == null)
{
ULog.Warning("CloseOtherSubMenusInParent 호출 시 currentMenuItemsParent가 null입니다.");
return;
}
for (int i = 0; i < currentMenuItemsParent.childCount; i++)
{
Transform siblingMenuItemTransform = currentMenuItemsParent.GetChild(i);
if (siblingMenuItemTransform == null || siblingMenuItemTransform == subMenuToExclude?.parent) continue;
Transform potentialSubMenu = siblingMenuItemTransform.Find("SubMenuContainer");
if (potentialSubMenu != null && potentialSubMenu != subMenuToExclude && potentialSubMenu.gameObject.activeSelf)
{
potentialSubMenu.gameObject.SetActive(false);
}
}
}
public virtual void CloseAllOpenSubMenus()
{
bool anyActuallyClosed = false;
// _menuItemObjects에는 모든 레벨의 메뉴 아이템 GameObject가 포함될 수 있습니다.
// 최상위 메뉴 아이템부터 순회하며 하위 메뉴를 닫는 것이 더 안전할 수 있습니다.
// 또는, 모든 SubMenuContainer를 직접 찾아 닫습니다.
// 현재 구현은 _menuItemObjects를 순회합니다.
foreach (GameObject menuItemGO in _menuItemObjects.Values)
{
if (menuItemGO == null) continue;
// IsSeparator인 경우 SubMenuContainer가 없을 것이므로, itemData를 통해 확인하거나,
// 단순히 Find로 null 체크만 해도 됩니다.
MenuItemData data = null; // 실제로는 itemID로 _menuItemData 딕셔너리에서 찾아야 함 (현재 없음)
// 여기서는 itemID로 _menuItemObjects에서 가져온 GO이므로, 해당 GO가 separator인지 알 수 없음.
// 따라서, 그냥 Find로 처리합니다.
// if(menuItemGO.name.StartsWith("Separator_")) continue; // 이름 기반으로 구분선 건너뛰기 (덜 안전함)
Transform subMenuContainer = menuItemGO.transform.Find("SubMenuContainer");
if (subMenuContainer != null && subMenuContainer.gameObject.activeSelf)
{
subMenuContainer.gameObject.SetActive(false);
anyActuallyClosed = true;
}
}
if (anyActuallyClosed || isAnySubMenuOpen)
{
isAnySubMenuOpen = false;
UpdateBlockerVisibility();
}
}
protected virtual void CheckIfAnySubMenuRemainsOpen()
{
isAnySubMenuOpen = false;
foreach (GameObject menuItemGO in _menuItemObjects.Values)
{
if (menuItemGO == null) continue;
// if(menuItemGO.name.StartsWith("Separator_")) continue;
Transform subMenuContainer = menuItemGO.transform.Find("SubMenuContainer");
if (subMenuContainer != null && subMenuContainer.gameObject.activeSelf)
{
isAnySubMenuOpen = true;
break;
}
}
}
protected virtual void UpdateBlockerVisibility()
{
if (uiBlockerInstance != null)
{
uiBlockerInstance.SetActive(isAnySubMenuOpen);
if (isAnySubMenuOpen)
{
// 블로커가 활성화될 때, 메뉴 시스템 바로 뒤(UI상 바로 앞)에 오도록 합니다.
// menuContainer가 Canvas의 직계 자식이라고 가정하고, 그 앞에 블로커를 둡니다.
// 더 복잡한 계층 구조에서는 이 부분이 조정되어야 할 수 있습니다.
if (menuContainer != null && uiBlockerInstance.transform.parent == menuContainer.parent)
{
uiBlockerInstance.transform.SetSiblingIndex(menuContainer.GetSiblingIndex());
}
// 만약 uiBlockerInstance가 Canvas 직속이고, menuContainer는 다른 곳에 있다면,
// menuContainer를 포함하는 최상위 UI 요소의 sibling index를 기준으로 해야 합니다.
// 여기서는 CreateUIBlocker에서 설정한 부모와 sibling index를 유지하거나,
// 필요시 menuContainer의 루트 캔버스 부모를 기준으로 다시 설정합니다.
}
}
}
public virtual void ClearMenuItems()
{
CloseAllOpenSubMenus();
foreach (var pair in _menuItemObjects)
{
if (pair.Value != null) Destroy(pair.Value);
}
_menuItemObjects.Clear();
if (menuContainer != null)
{
foreach (Transform child in menuContainer)
{
Destroy(child.gameObject);
}
}
}
public virtual void UpdateAllMenuTexts(List<MenuItemData> items)
{
if (_locManager == null)
{
ULog.Warning("LocalizationManager가 없어 메뉴 텍스트를 업데이트할 수 없습니다.");
return;
}
UpdateMenuTextsRecursive(items);
}
protected virtual void UpdateMenuTextsRecursive(List<MenuItemData> items)
{
if (items == null) return;
foreach (var itemData in items)
{
if (itemData.IsSeparator) continue;
if (_menuItemObjects.TryGetValue(itemData.ItemId, out GameObject menuItemInstance))
{
TextMeshProUGUI buttonText = menuItemInstance.GetComponentInChildren<TextMeshProUGUI>(true);
if (buttonText != null && !string.IsNullOrEmpty(itemData.DisplayNameKey))
{
buttonText.text = _locManager.GetString(itemData.DisplayNameKey);
}
}
if (itemData.SubMenuItems != null && itemData.SubMenuItems.Count > 0)
{
UpdateMenuTextsRecursive(itemData.SubMenuItems);
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ec70649e0f460ce458cf6d62498ecf20