코드 정리 및 DataValidator 추가, HttpPipeLine 쓰레드에서 실행되도록 수정

This commit is contained in:
logonkhi
2025-06-24 19:29:37 +09:00
parent 3acff06eca
commit b3bf7e6eff
50 changed files with 1544 additions and 3064 deletions

View File

@@ -58,6 +58,7 @@ namespace UVC.Data
/// </summary>
private DataMask mask;
/// <summary>
/// 병렬 처리를 적용할 배열의 최소 크기
/// </summary>

View File

@@ -4,7 +4,6 @@ using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
using Unity.VisualScripting.Antlr3.Runtime;
namespace UVC.Data
{

View File

@@ -0,0 +1,358 @@
#nullable enable
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
namespace UVC.Data
{
/// <summary>
/// JSON 데이터의 유효성을 검사하는 클래스입니다.
/// 이 클래스는 특정 속성(프로퍼티)에 대한 유효성 검사 규칙을 추가하고,
/// 데이터가 해당 규칙을 모두 충족하는지 확인할 수 있습니다.
/// </summary>
/// <example>
/// <code>
/// // 유효성 검사기 생성
/// var _validator = new DataValidator();
///
/// // 검증 규칙 추가: name 속성은 비어있지 않아야 함
/// _validator.AddValidator("name", value => value is string name && !string.IsNullOrEmpty(name));
///
/// // 검증 규칙 추가: age 속성은 18 이상이어야 함
/// _validator.AddValidator("age", value => value is long age && age >= 18);
///
/// // 데이터 검증 예시
/// var userData = new JObject {
/// { "name", "홍길동" },
/// { "age", 20 }
/// };
///
/// if (_validator.IsValid(userData))
/// {
/// Console.WriteLine("유효한 사용자 데이터입니다.");
/// }
/// else
/// {
/// Console.WriteLine("유효하지 않은 사용자 데이터입니다.");
/// }
/// </code>
/// </example>
public class DataValidator
{
/// <summary>
/// 유효성 검사 규칙의 목록입니다.
/// 각 사전은 속성 이름(string)과 해당 속성의 검증 함수(Func&lt;object, bool&gt;)를 포함합니다.
/// </summary>
private List<Dictionary<string, Func<object, bool>>> validaters = new List<Dictionary<string, Func<object, bool>>>();
/// <summary>
/// 대용량 JSON 데이터를 스트리밍 방식으로 처리할지 여부를 나타내는 속성입니다.
/// 스트리밍 방식은 메모리 사용량을 줄이기 위해 대용량 데이터를 처리할 때 유용합니다.
/// </summary>
/// <remarks>
/// true로 설정하면 대용량 JSON 데이터를 처리할 때 스트리밍 방식을 사용합니다.
/// false로 설정하면 항상 전체 데이터를 메모리에 로드합니다.
/// </remarks>
public bool SupportsStreamParsing { get; internal set; } = true;
/// <summary>
/// 스트리밍 방식을 적용할 JSON 데이터의 최소 길이(바이트)입니다.
/// 데이터 길이가 이 값보다 크면 스트리밍 방식으로 처리합니다.
/// </summary>
/// <remarks>
/// 기본값은 10,000바이트입니다. 작은 데이터의 경우 스트리밍 처리가 오히려
/// 오버헤드를 발생시킬 수 있으므로, 특정 크기 이상일 때만 스트리밍 방식을 적용합니다.
/// </remarks>
public int SupportsStreamLength { get; internal set; } = 10000;
/// <summary>
/// 새로운 유효성 검사 규칙을 추가합니다.
/// </summary>
/// <param name="propertyName">검사할 속성 이름</param>
/// <param name="validator">유효성 검사 함수 (매개변수: 속성값, 반환값: 유효성 여부)</param>
/// <example>
/// <code>
/// var _validator = new DataValidator();
///
/// // 문자열 속성이 비어있지 않은지 확인하는 규칙
/// _validator.AddValidator("username", value =>
/// {
/// if (value is string str)
/// return !string.IsNullOrEmpty(str);
/// return false;
/// });
///
/// // 숫자 속성이 특정 범위 내에 있는지 확인하는 규칙
/// _validator.AddValidator("score", value =>
/// {
/// if (value is long num)
/// return num >= 0 && num <= 100;
/// return false;
/// });
/// </code>
/// </example>
public void AddValidator(string propertyName, Func<object, bool> validator)
{
// 유효성 검사기를 추가합니다.
validaters.Add(new Dictionary<string, Func<object, bool>> { { propertyName, validator } });
}
/// <summary>
/// 특정 속성에 대한 모든 유효성 검사 규칙을 제거합니다.
/// </summary>
/// <param name="propertyName">규칙을 제거할 속성 이름</param>
/// <example>
/// <code>
/// var _validator = new DataValidator();
/// _validator.AddValidator("age", value => (long)value >= 18);
///
/// // 검증 과정 중에 'age' 속성 검사가 더 이상 필요하지 않을 경우
/// _validator.RemoveValidator("age");
/// </code>
/// </example>
public void RemoveValidator(string propertyName)
{
// 유효성 검사기를 제거합니다.
validaters.RemoveAll(v => v.ContainsKey(propertyName));
}
/// <summary>
/// DataObject 객체가 모든 유효성 검사 규칙을 통과하는지 확인합니다.
/// </summary>
/// <param name="data">검사할 DataObject</param>
/// <returns>모든 규칙을 통과하면 true, 하나라도 실패하면 false</returns>
/// <example>
/// <code>
/// var _validator = new DataValidator();
/// _validator.AddValidator("email", value => {
/// var emailStr = value as string;
/// return emailStr != null && emailStr.Contains("@");
/// });
///
/// var userData = new DataObject();
/// userData["name"] = "홍길동";
/// userData["email"] = "hong@example.com";
///
/// bool isValid = _validator.IsValid(userData); // true 반환
/// </code>
/// </example>
public bool IsValid(DataObject? data)
{
// 데이터가 null인 경우 유효하지 않음
if (data == null)
{
return false;
}
// 각 유효성 검사기를 순회하며 검증을 수행합니다.
foreach (var validator in validaters)
{
foreach (var kvp in validator)
{
// 해당 속성이 데이터에 존재하는지 확인
if (!data.ContainsKey(kvp.Key))
{
continue; // 속성이 없으면 검증을 건너뜀
}
// 속성 값에 대한 검증을 수행
if (!kvp.Value(data[kvp.Key]))
{
// 검증에 실패하면 false를 반환
return false;
}
}
}
// 모든 검증을 통과하면 유효함
return true;
}
/// <summary>
/// JObject가 모든 유효성 검사 규칙을 통과하는지 확인합니다.
/// </summary>
/// <param name="data">검사할 JObject</param>
/// <returns>모든 규칙을 통과하면 true, 하나라도 실패하면 false</returns>
/// <example>
/// <code>
/// var _validator = new DataValidator();
///
/// // 사용자 나이가 18세 이상인지 확인하는 규칙
/// _validator.AddValidator("age", value =>
/// value is long age && age >= 18
/// );
///
/// // JSON 데이터 생성
/// var jsonObject = JObject.Parse(@"{
/// ""name"": ""김철수"",
/// ""age"": 16
/// }");
///
/// bool isValid = _validator.IsValid(jsonObject); // false 반환 (age가 18 미만)
/// </code>
/// </example>
public bool IsValid(JObject? data)
{
// 데이터가 null인 경우 유효하지 않음
if (data == null)
{
return false;
}
// 각 유효성 검사기를 순회하며 검증을 수행합니다.
foreach (var validator in validaters)
{
foreach (var kvp in validator)
{
// 해당 속성이 데이터에 존재하는지 확인
if (!data.ContainsKey(kvp.Key))
{
continue; // 속성이 없으면 검증을 건너뜀
}
// 속성 값에 대한 검증을 수행
if (!kvp.Value(data[kvp.Key]))
{
// 검증에 실패하면 false를 반환
return false;
}
}
}
// 모든 검증을 통과하면 유효함
return true;
}
/// <summary>
/// JSON 데이터 스트림이 모든 유효성 검사 규칙을 통과하는지 확인합니다.
/// 대용량 JSON 데이터를 메모리 효율적으로 처리할 수 있습니다.
/// </summary>
/// <param name="jsonStream">검사할 JSON 데이터 스트림</param>
/// <returns>모든 규칙을 통과하면 true, 하나라도 실패하면 false</returns>
/// <example>
/// <code>
/// var _validator = new DataValidator();
/// _validator.AddValidator("isActive", value => value is bool active && active == true);
///
/// // 파일에서 JSON 데이터 스트림을 읽어 유효성 검사
/// using (var fileStream = File.OpenRead("user-data.json"))
/// {
/// bool isValid = _validator.IsValid(fileStream);
/// Console.WriteLine($"데이터 유효성: {isValid}");
/// }
/// </code>
/// </example>
public bool IsValid(System.IO.Stream jsonStream)
{
if (jsonStream == null) return false;
// 스트림 처리 최적화를 위해 청크 단위로 읽을 수 있지만,
// 현재는 Newtonsoft.Json의 기본 역직렬화 사용
using (var reader = new Newtonsoft.Json.JsonTextReader(new System.IO.StreamReader(jsonStream)))
{
// 청크 읽기 설정 - 메모리 사용량 최적화
reader.SupportMultipleContent = true;
var serializer = new Newtonsoft.Json.JsonSerializer();
var sourceObject = serializer.Deserialize<JObject>(reader);
return IsValid(sourceObject);
}
}
/// <summary>
/// JSON 배열에서 유효성 검사 규칙을 통과하는 항목만 필터링하여 반환합니다.
/// </summary>
/// <param name="data">필터링할 JSON 배열</param>
/// <returns>유효한 항목만 포함된 새 JSON 배열 (유효한 항목이 없으면 null)</returns>
/// <example>
/// <code>
/// var _validator = new DataValidator();
///
/// // 유효성 검사 규칙: status가 'active'인 항목만 유효
/// _validator.AddValidator("status", value =>
/// value is string status && status == "active"
/// );
///
/// // 필터링할 JSON 배열 생성
/// var usersArray = JArray.Parse(@"[
/// {""id"": 1, ""name"": ""홍길동"", ""status"": ""active""},
/// {""id"": 2, ""name"": ""김철수"", ""status"": ""inactive""},
/// {""id"": 3, ""name"": ""이영희"", ""status"": ""active""}
/// ]");
///
/// // 유효한 항목(status가 'active'인 항목)만 필터링
/// JArray? validUsers = _validator.GetValidData(usersArray);
///
/// // validUsers는 홍길동과 이영희의 데이터만 포함 (2개 항목)
/// Console.WriteLine($"유효한 사용자 수: {validUsers?.Count ?? 0}");
/// </code>
/// </example>
public JArray? GetValidData(JArray? data)
{
if (data == null || data.Count == 0)
{
return data; // 빈 배열은 유효하지 않음
}
JArray validData = new JArray();
foreach (var item in data)
{
if (item is JObject jObject && IsValid(jObject))
{
validData.Add(jObject);
}
}
return validData.Count > 0 ? validData : null; // 유효한 데이터가 없으면 null 반환
}
/// <summary>
/// JSON 데이터 스트림에서 유효성 검사 규칙을 통과하는 항목만 필터링하여 반환합니다.
/// 대용량 JSON 배열을 메모리 효율적으로 처리하기 위해 스트리밍 방식을 사용합니다.
/// </summary>
/// <param name="jsonStream">필터링할 JSON 배열 데이터 스트림</param>
/// <returns>유효한 항목만 포함된 JSON 배열 (유효한 항목이 없으면 null)</returns>
/// <example>
/// <code>
/// var _validator = new DataValidator();
///
/// // 필수 필드가 모두 있는지 확인하는 규칙 추가
/// _validator.AddValidator("name", value => value != null &amp;&amp; !string.IsNullOrEmpty(value.ToString()));
/// _validator.AddValidator("email", value => value != null &amp;&amp; value.ToString().Contains("@"));
///
/// // 파일에서 JSON 배열 데이터 스트림을 읽어 유효한 항목만 필터링
/// using (var fileStream = File.OpenRead("users-data.json"))
/// {
/// JArray? validUsers = _validator.GetValidData(fileStream);
///
/// if (validUsers != null)
/// {
/// Console.WriteLine($"유효한 사용자 수: {validUsers.Count}");
///
/// // 첫 번째 유효한 사용자 정보 출력
/// if (validUsers.Count > 0)
/// {
/// var firstUser = validUsers[0];
/// Console.WriteLine($"첫 번째 유효 사용자: {firstUser["name"]}");
/// }
/// }
/// else
/// {
/// Console.WriteLine("유효한 사용자가 없습니다.");
/// }
/// }
/// </code>
/// </example>
public JArray? GetValidData(System.IO.Stream jsonStream)
{
if (jsonStream == null) return null;
using (var reader = new Newtonsoft.Json.JsonTextReader(new System.IO.StreamReader(jsonStream)))
{
// 청크 읽기 설정 - 메모리 사용량 최적화
reader.SupportMultipleContent = true;
var serializer = new Newtonsoft.Json.JsonSerializer();
var sourceArray = serializer.Deserialize<JArray>(reader);
return GetValidData(sourceArray);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 74866795406b7c74a86591446c9b455a

View File

@@ -12,6 +12,7 @@ using UnityEngine;
using UVC.Log;
using UVC.Network;
using UVC.Tests;
using UVC.Threading;
namespace UVC.Data
{
@@ -29,6 +30,11 @@ namespace UVC.Data
/// - 안전한 요청 취소 및 자원 정리
/// - 테스트를 위한 목업 기능 지원
///
/// 모든 HTTP 요청은 백그라운드 스레드(스레드풀)에서 처리되어 메인 스레드 차단을 방지합니다.
/// 요청 결과 처리 시 핸들러(SuccessHandler, FailHandler)는 자동으로 메인 스레드에서 호출됩니다.
/// 이를 통해 UI 스레드 차단 없이 효율적인 네트워크 작업을 수행하면서도,
/// UI 업데이트는 안전하게 메인 스레드에서 처리할 수 있습니다.
///
/// 모든 반복 실행은 CancellationTokenSource를 통해 취소할 수 있으며,
/// 취소 후 현재 진행 중인 모든 요청이 안전하게 완료되는 것을 보장합니다.
/// </remarks>
@@ -142,22 +148,33 @@ namespace UVC.Data
/// 지정한 키의 HTTP 요청을 실행합니다.
/// </summary>
/// <param name="key">실행할 요청의 키</param>
/// <param name="switchToMainThread">메인 스레드로 전환할지 여부</param>
/// <returns>비동기 작업</returns>
/// <remarks>
/// 요청 정보의 repeat 속성에 따라 단일 실행 또는 반복 실행을 시작합니다.
/// 이미 실행 중인 반복 작업이 있다면 먼저 중지하고 완료를 대기한 후 새로운 요청을 시작합니다.
/// 단일 실행의 경우 완료될 때까지 대기하지만, 반복 실행은 백그라운드에서 실행됩니다.
///
/// 모든 HTTP 요청 처리는 백그라운드 스레드에서 수행되며, 핸들러만 메인 스레드에서 호출됩니다.
/// </remarks>
/// <exception cref="KeyNotFoundException">지정된 키가 등록되어 있지 않은 경우</exception>
public async UniTask Excute(string key)
public async UniTask Excute(string key, bool switchToMainThread = false)
{
if (infoList.ContainsKey(key))
if (!infoList.ContainsKey(key))
{
HttpPipeLineInfo info = infoList[key];
throw new KeyNotFoundException($"No HTTP request found with key '{key}'.");
}
// 반복 설정에 관계없이 이전에 실행 중인 반복 작업이 있다면 중지
await StopRepeat(key);
HttpPipeLineInfo info = infoList[key];
// 반복 설정에 관계없이 이전에 실행 중인 반복 작업이 있다면 중지
await StopRepeat(key);
// 스레드풀에서 요청 처리 실행
await UniTask.SwitchToThreadPool();
try
{
if (!info.Repeat)
{
// 단일 실행 로직 호출
@@ -166,12 +183,19 @@ namespace UVC.Data
else
{
// 반복 설정이 있는 경우에만 StartRepeat 호출
// Forget()을 호출하지 않고 StartRepeat가 스레드풀에서 계속 실행되도록 함
StartRepeat(key).Forget();
}
}
else
finally
{
throw new KeyNotFoundException($"No HTTP request found with key '{key}'.");
// ExecuteSingle 또는 StartRepeat.Forget() 후 메인 스레드로 복귀하는 것은 선택 사항
// 여기서는 원래 실행 컨텍스트로 돌아가기 위해 메인 스레드로 전환
// 만약 계속해서 백그라운드 스레드에서 실행하려면 이 코드를 제거할 수 있음
if (switchToMainThread)
{
await UniTask.SwitchToMainThread();
}
}
}
@@ -183,8 +207,9 @@ namespace UVC.Data
/// <param name="cancellationToken">요청 취소를 위한 취소 토큰</param>
/// <returns>비동기 작업</returns>
/// <remarks>
/// 이 메서드는 HTTP 요청을 보내고, 응답 데이터를 파싱하여 IDataObject로 변환합니다.
/// 이 메서드는 백그라운드 스레드에서 HTTP 요청을 보내고, 응답 데이터를 파싱하여 IDataObject로 변환합니다.
/// JSON 객체 또는 배열 형식의 응답을 처리할 수 있으며, 취소 토큰을 통해 언제든지 작업을 취소할 수 있습니다.
/// 모든 핸들러 호출은 메인 스레드에서 이루어져 UI 업데이트를 안전하게 수행할 수 있습니다.
/// </remarks>
/// <exception cref="OperationCanceledException">작업이 취소된 경우 발생</exception>
/// <exception cref="JsonException">JSON 응답 파싱 중 오류가 발생한 경우</exception>
@@ -237,7 +262,16 @@ namespace UVC.Data
// 응답 마스크 적용 결과가 성공이 아니면 실패 핸들러 호출 후 반환
if (!responseResult.IsSuccess)
{
info.FailHandler?.Invoke(responseResult.Message!);
if (info.FailHandler != null)
{
string errorMessage = responseResult.Message!;
// UI 스레드에서 실패 핸들러 호출
MainThreadDispatcher.Instance.SendToMainThread(() =>
{
info.FailHandler.Invoke(errorMessage);
});
}
return;
}
else
@@ -248,6 +282,44 @@ namespace UVC.Data
if (result.StartsWith("{"))
{
if (info.Validator != null)
{
if (info.Validator.SupportsStreamParsing && result.Length > info.Validator.SupportsStreamLength)
{
using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(result)))
{
if (info.Validator != null && !info.Validator.IsValid(stream))
{
if (info.FailHandler != null)
{
// UI 스레드에서 실패 핸들러 호출
MainThreadDispatcher.Instance.SendToMainThread(() =>
{
info.FailHandler.Invoke("Data is not Valid");
});
}
return;
}
}
}
else
{
JObject source = JObject.Parse(result);
if (info.Validator != null && !info.Validator.IsValid(source))
{
if (info.FailHandler != null)
{
// UI 스레드에서 실패 핸들러 호출
MainThreadDispatcher.Instance.SendToMainThread(() =>
{
info.FailHandler.Invoke("Data is not Valid");
});
}
return;
}
}
}
if (info.DataMapper != null)
{
if (info.DataMapper.SupportsStreamParsing && result.Length > info.DataMapper.SupportsStreamLength)
@@ -266,19 +338,81 @@ namespace UVC.Data
}
else if (result.StartsWith("["))
{
if (info.Validator != null)
{
if (info.Validator.SupportsStreamParsing && result.Length > info.Validator.SupportsStreamLength)
{
using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(result)))
{
JArray? validSource = info.Validator.GetValidData(stream);
if (validSource == null)
{
if (info.FailHandler != null)
{
// UI 스레드에서 실패 핸들러 호출
MainThreadDispatcher.Instance.SendToMainThread(() =>
{
info.FailHandler.Invoke("Data is not Valid");
});
}
return;
}
}
}
else
{
JArray source = JArray.Parse(result);
JArray? validSource = info.Validator.GetValidData(source);
if (validSource == null)
{
if (info.FailHandler != null)
{
// UI 스레드에서 실패 핸들러 호출
MainThreadDispatcher.Instance.SendToMainThread(() =>
{
info.FailHandler.Invoke("Data is not Valid");
});
}
return;
}
}
}
if (info.DataMapper != null)
{
if (info.DataMapper.SupportsStreamParsing && result.Length > info.DataMapper.SupportsStreamLength)
{
using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(result)))
{
dataObject = info.DataMapper.MapArrayStream(stream);
if (info.Validator != null)
{
JArray? validSource = info.Validator.GetValidData(stream);
if (validSource != null)
{
dataObject = info.DataMapper.Map(validSource);
}
}
else
{
dataObject = info.DataMapper.MapArrayStream(stream);
}
}
}
else
{
JArray source = JArray.Parse(result);
dataObject = info.DataMapper.Map(source);
if (info.Validator != null)
{
JArray? validSource = info.Validator.GetValidData(source);
if (validSource != null)
{
dataObject = info.DataMapper.Map(validSource);
}
}
else
{
dataObject = info.DataMapper.Map(source);
}
}
}
}
@@ -300,12 +434,47 @@ namespace UVC.Data
// 갱신 된 데이터가 있는 경우 핸들러 호출
if (info.UpdatedDataOnly)
{
if (dataObject != null && dataObject.UpdatedCount > 0) info.SuccessHandler?.Invoke(dataObject);
if (dataObject != null && dataObject.UpdatedCount > 0)
{
if (info.SuccessHandler != null)
{
// 로컬 변수로 복사하여 클로저에서 안전하게 사용
var handlerData = dataObject;
// UI 스레드에서 성공 핸들러 호출
MainThreadDispatcher.Instance.SendToMainThread(() =>
{
info.SuccessHandler.Invoke(handlerData);
});
}
return;
}
}
if (dataObject != null)
{
if (info.SuccessHandler != null)
{
// 로컬 변수로 복사하여 클로저에서 안전하게 사용
var handlerData = dataObject;
// UI 스레드에서 성공 핸들러 호출
MainThreadDispatcher.Instance.SendToMainThread(() =>
{
info.SuccessHandler.Invoke(handlerData);
});
}
}
else
{
info.SuccessHandler?.Invoke(dataObject);
if (info.FailHandler != null)
{
// UI 스레드에서 실패 핸들러 호출
MainThreadDispatcher.Instance.SendToMainThread(() =>
{
info.FailHandler.Invoke("Data is Null");
});
}
}
return;
}
catch (OperationCanceledException)
@@ -349,10 +518,12 @@ namespace UVC.Data
/// <param name="key">반복 실행할 요청의 키</param>
/// <returns>비동기 작업</returns>
/// <remarks>
/// 지정된 간격(repeatInterval)으로 HTTP 요청을 반복 실행합니다.
/// 지정된 간격(repeatInterval)으로 HTTP 요청을 백그라운드 스레드에서 반복 실행합니다.
/// repeatCount가 0인 경우 무한 반복하며, 0보다 큰 경우 지정된 횟수만큼만 실행합니다.
/// 작업 실행 중 예외가 발생하면 로그를 기록하고 다음 실행을 시도합니다.
/// 취소 요청이 있거나 최대 실행 횟수에 도달하면 반복이 종료됩니다.
///
/// 이 메서드는 백그라운드 스레드에서 실행되며, 모든 핸들러 호출은 메인 스레드에서 이루어집니다.
/// </remarks>
/// <exception cref="KeyNotFoundException">지정된 키가 등록되어 있지 않은 경우</exception>
private async UniTask StartRepeat(string key)

View File

@@ -42,6 +42,42 @@ namespace UVC.Data
private int _retryDelay = 1000; // 밀리초
private bool _updatedDataOnly = false; // 업데이트된 데이터만 받을 여부
/// <summary>
/// HTTP 응답 데이터의 유효성을 검사하는 데 사용되는 검사기입니다.
/// 이 검사기를 통해 특정 조건을 만족하는 데이터만 처리하도록 필터링할 수 있습니다.
/// </summary>
/// <remarks>
/// 데이터 유효성 검사는 HTTP 응답이 성공적으로 수신되고 JSON으로 파싱된 후,
/// 성공 핸들러에 전달되기 전에 수행됩니다.
/// 검사기가 null이 아닌 경우에만 유효성 검사가 실행됩니다.
/// </remarks>
/// <example>
/// <code>
/// // DataValidator 생성 및 규칙 추가
/// var validator = new DataValidator();
///
/// // "status" 필드가 "active"인 데이터만 유효하도록 설정
/// validator.AddValidator("status", value =>
/// value is string status && status.Equals("active", StringComparison.OrdinalIgnoreCase));
///
/// // "temperature" 필드가 유효한 범위 내에 있는지 확인
/// validator.AddValidator("temperature", value => {
/// if (value is double temp)
/// return temp >= -50 && temp <= 100;
/// return false;
/// });
///
/// // 파이프라인에 validator 설정
/// var pipeline = new HttpPipeLineInfo("https://api.weather.com/current")
/// .setValidator(validator)
/// .setSuccessHandler(data => {
/// // 여기에 전달되는 데이터는 이미 유효성 검사를 통과한 데이터만 포함됩니다.
/// Console.WriteLine($"유효한 온도: {data["temperature"]}°C");
/// });
/// </code>
/// </example>
private DataValidator? _validator;
/// <summary>
/// 요청을 보낼 URL 주소
/// </summary>
@@ -92,6 +128,16 @@ namespace UVC.Data
/// </summary>
public DataMapper? DataMapper => _dataMapper;
/// <summary>
/// HTTP 응답 데이터의 유효성을 검사하는 검사기입니다.
/// 이 속성을 통해 설정된 규칙에 따라 HTTP 응답 데이터를 필터링할 수 있습니다.
/// </summary>
/// <remarks>
/// null이 아닌 경우, HTTP 응답이 파싱된 후 데이터가 핸들러에 전달되기 전에
/// 데이터가 검사기의 모든 규칙을 통과하는지 확인합니다.
/// </remarks>
public DataValidator? Validator => _validator;
/// <summary>
/// HTTP 응답의 성공 여부를 확인하고, 성공 시 실제 데이터 페이로드를 추출하는 데 사용되는 <see cref="Data.HttpResponseMask"/>입니다.
/// 이 객체에 정의된 규칙에 따라 원시 응답 문자열이 처리됩니다.
@@ -140,6 +186,55 @@ namespace UVC.Data
return this;
}
/// <summary>
/// HTTP 응답 데이터의 유효성을 검사하는 검사기를 설정합니다.
/// </summary>
/// <param name="validator">HTTP 응답 데이터의 유효성 검사에 사용할 DataValidator 객체</param>
/// <returns>현재 HttpPipeLineInfo 인스턴스 (메서드 체이닝용)</returns>
/// <remarks>
/// 이 메서드로 설정된 검사기는 HTTP 응답이 성공적으로 파싱된 후,
/// 성공 핸들러에 전달되기 전에 데이터의 유효성을 검사합니다.
/// 데이터가 모든 유효성 검사 규칙을 통과하지 못하면 성공 핸들러가 호출되지 않습니다.
/// </remarks>
/// <example>
/// <code>
/// // 1. DataValidator 생성 및 규칙 추가
/// var validator = new DataValidator();
///
/// // 2. 유효성 검사 규칙 정의
/// // 사용자 데이터에 이메일이 포함되어 있는지 확인
/// validator.AddValidator("email", value => {
/// if (value is string email)
/// return email.Contains("@") && email.Contains(".");
/// return false;
/// });
///
/// // 사용자 나이가 18세 이상인지 확인
/// validator.AddValidator("age", value => {
/// if (value is long age)
/// return age >= 18;
/// return false;
/// });
///
/// // 3. 검사기를 HTTP 파이프라인에 설정
/// var pipelineInfo = new HttpPipeLineInfo("https://api.example.com/users", "get")
/// .setDataMapper(userDataMapper)
/// .setValidator(validator)
/// .setSuccessHandler(userData => {
/// // 여기에 도달하는 사용자 데이터는 모두 이메일이 유효하고 18세 이상입니다.
/// Console.WriteLine($"유효한 사용자: {userData["name"]}, {userData["email"]}");
/// })
/// .setFailHandler(errorMsg => {
/// Console.WriteLine($"요청 실패: {errorMsg}");
/// });
/// </code>
/// </example>
public HttpPipeLineInfo setValidator(DataValidator validator)
{
this._validator = validator;
return this;
}
/// <summary>
/// HTTP 파이프라인에 적용할 ResponseMask를 설정하고 업데이트된 파이프라인 구성을 반환합니다.
/// </summary>

View File

@@ -205,12 +205,32 @@ namespace UVC.Data
if (message.StartsWith("{"))
{
JObject source = JObject.Parse(message);
if (info.Validator != null && !info.Validator.IsValid(source))
{
return; // 유효성 검사 실패 시 핸들러 호출을 중단
}
if (info.DataMapper != null) dataObject = info.DataMapper.Map(source);
}
else if (message.StartsWith("["))
{
JArray source = JArray.Parse(message);
if (info.DataMapper != null) dataObject = info.DataMapper.Map(source);
if (info.Validator != null)
{
JArray? validSource = info.Validator.GetValidData(source);
if (validSource != null && validSource.Count > 0)
{
// 유효한 데이터가 있는 경우에만 매핑
if (info.DataMapper != null) dataObject = info.DataMapper.Map(validSource);
}
else
{
return; // 유효성 검사 실패 시 핸들러 호출을 중단
}
}
else
{
if (info.DataMapper != null) dataObject = info.DataMapper.Map(source);
}
}
if (dataObject != null) dataObject = DataRepository.Instance.AddData(topic, dataObject, info.UpdatedDataOnly);

View File

@@ -32,6 +32,37 @@ namespace UVC.Data
private DataMapper? _dataMapper = null; // 데이터 매퍼
private bool _updatedDataOnly = false; // 업데이트된 데이터만 받을 여부
/// <summary>
/// MQTT 메시지 데이터의 유효성을 검사하는 데 사용되는 검사기입니다.
/// 메시지가 특정 조건이나 규칙을 만족하는지 확인하는 데 활용됩니다.
/// </summary>
/// <remarks>
/// 데이터 유효성 검사는 메시지를 필터링하여 유효한 데이터만 처리하도록 합니다.
/// 예를 들어, 필수 필드가 있는지, 값의 범위가 적절한지 등을 검사할 수 있습니다.
/// null인 경우 유효성 검사를 수행하지 않습니다.
/// </remarks>
/// <example>
/// <code>
/// // DataValidator 생성 및 규칙 추가
/// var _validator = new DataValidator();
///
/// // 온도 값이 -50°C ~ 100°C 범위 내에 있는지 검사
/// _validator.AddValidator("temperature", value =>
/// value is double temp && temp >= -50 && temp <= 100);
///
/// // 습도 값이 0% ~ 100% 범위 내에 있는지 검사
/// validator.AddValidator("humidity", value =>
/// value is double humidity && humidity >= 0 && humidity <= 100);
///
/// // 파이프라인에 validator 설정
/// var pipelineInfo = new MQTTPipeLineInfo("sensors/readings")
/// .setDataMapper(dataMapper)
/// .setValidator(validator)
/// .setHandler(data => ProcessValidSensorData(data));
/// </code>
/// </example>
private DataValidator? _validator;
/// <summary>
/// MQTT 토픽
/// </summary>
@@ -47,6 +78,11 @@ namespace UVC.Data
/// </summary>
public DataMapper? DataMapper => _dataMapper;
/// <summary>
/// <see cref="DataValidator"/> 인스턴스를 사용하여 데이터 유효성을 검사합니다.
/// </summary>
public DataValidator? Validator => _validator;
/// <summary>
/// 업데이트된 데이터만 받을 여부 (true로 설정하면, 데이터가 변경된 경우에만 핸들러가 호출됩니다)
/// </summary>
@@ -110,5 +146,49 @@ namespace UVC.Data
return this;
}
/// <summary>
/// MQTT 메시지 데이터의 유효성을 검사하는 데 사용할 Validator를 설정합니다.
/// </summary>
/// <param name="validator">데이터 유효성 검사기</param>
/// <returns>현재 MQTTPipeLineInfo 인스턴스 (메서드 체이닝용)</returns>
/// <remarks>
/// 유효성 검사기는 수신된 MQTT 메시지 데이터가 유효한지 확인하는 역할을 합니다.
/// 데이터가 특정 조건을 만족하는지 확인하고, 유효하지 않은 메시지는 필터링할 수 있습니다.
/// 파이프라인 내에서 DataMapper가 데이터를 변환한 후, Validator가 유효성을 검사하고,
/// 유효한 데이터만 Handler에게 전달됩니다.
/// </remarks>
/// <example>
/// <code>
/// // DataValidator 생성 및 설정
/// var _validator = new DataValidator();
///
/// // 온도 값이 -50°C ~ 100°C 범위 내에 있는지 검사
/// _validator.AddValidator("temperature", value => {
/// if (value is double temp)
/// return temp >= -50 && temp <= 100;
/// return false;
/// });
///
/// // deviceId가 null이 아니고 비어있지 않은지 검사
/// validator.AddValidator("deviceId", value =>
/// value is string id && !string.IsNullOrEmpty(id));
///
/// // Validator를 파이프라인에 설정
/// var pipelineInfo = new MQTTPipeLineInfo("sensors/data")
/// .setDataMapper(dataMapper)
/// .setValidator(_validator)
/// .setHandler(data => {
/// // 여기서 처리되는 데이터는 모두 유효성 검사를 통과한 데이터
/// Console.WriteLine($"유효한 센서 데이터: {data["deviceId"]} - {data["temperature"]}°C");
/// });
/// </code>
/// </example>
public MQTTPipeLineInfo setValidator(DataValidator validator)
{
this._validator = validator;
return this;
}
}
}

View File

@@ -174,5 +174,15 @@ namespace UVC.Extension
return t;
}
/// <summary>
/// 지정된 <see cref="Transform"/>의 로컬 위치를 월드 공간 좌표로 변환합니다.
/// </summary>
/// <param name="t">로컬 위치를 변환할 <see cref="Transform"/>입니다.</param>
/// <returns> <paramref name="t"/> 로컬 위치의 월드 공간 좌표를 나타내는 <see cref="Vector3"/>입니다.</returns>
public static Vector3 ToWorldPosition(this Transform t)
{
return t.TransformPoint(t.localPosition);
}
}
}

View File

@@ -2,6 +2,7 @@
using TMPro;
using UnityEngine;
using UVC.Factory.Component;
using UVC.Util;
namespace UVC.Factory.Alarm
{

View File

@@ -1,4 +1,5 @@
using UnityEngine;
using UVC.Util;
namespace UVC.Factory.Alarm
{

View File

@@ -1,37 +0,0 @@
using System.Collections;
using UnityEngine;
using UVC.Core;
namespace UVC.Factory
{
public class CameraController : SingletonScene<CameraController>
{
public float moveSpeed = 5.0f;
public float rotateSpeed = 100.0f;
private Coroutine focusCoroutine;
public void FocusOnTarget(Transform target, float distance)
{
if (focusCoroutine != null)
{
StopCoroutine(focusCoroutine);
}
focusCoroutine = StartCoroutine(FocusRoutine(target, distance));
}
private IEnumerator FocusRoutine(Transform target, float distance)
{
Vector3 targetPosition = target.position - transform.forward * distance;
Quaternion targetRotation = Quaternion.LookRotation(target.position - transform.position);
while (Vector3.Distance(transform.position, targetPosition) > 0.01f)
{
transform.position = Vector3.Lerp(transform.position, targetPosition, Time.deltaTime * moveSpeed);
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * rotateSpeed);
yield return null;
}
}
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 1261d9494e4176a488c4e65b80396de6

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UVC.Data;
@@ -16,33 +17,31 @@ namespace UVC.Factory.Component
// 움직임과 회전의 부드러움을 조절할 속도 변수
// Unity 인스펙터 창에서 실시간으로 값을 조절하며 테스트할 수 있습니다.
[Tooltip("목표 지점까지의 이동 속도를 조절합니다.")]
public float moveSpeed = 2.0f;
public float moveSpeed = 1.0f;
[Tooltip("목표 방향까지의 회전 속도를 조절합니다.")]
public float rotationSpeed = 5.0f;
public float rotationSpeed = 2.0f;
[Tooltip("이 거리(미터)를 초과하면 보간 없이 즉시 위치를 변경합니다.")]
public float teleportDistanceThreshold = 5.0f; // 5미터 이상 차이나면 순간이동
private void Start()
{
targetPosition = transform.position;
targetRotation = transform.rotation;
DataOrderedMask = new List<string>
{
"Name",
"Type",
"Status",
"BatteryLevel",
"Location",
"LastMaintenance"
"VHL_NAME",
"NODE_ID",
"REAL_ID",
"VHL_STATE",
"BATT",
"JOB_ID",
"TIMESTAMP",
};
}
public override void OnPointerClick(PointerEventData eventData)
{
Debug.Log($"OnPointerClick3: {gameObject.name}");
base.OnPointerClick(eventData);
}
/// <summary>
/// 내부 상태를 업데이트하고 정렬된 마스크에 정의된 특정 키에 대한 작업을 수행하여 제공된 데이터 객체를 처리합니다.
// </summary>
@@ -119,11 +118,32 @@ namespace UVC.Factory.Component
void Update()
{
// 매 프레임마다 현재 위치에서 목표 위치로 부드럽게 이동시킵니다.
transform.position = Vector3.Lerp(transform.position, targetPosition, Time.deltaTime * moveSpeed);
if (transform.position != targetPosition)
{
if (Vector3.Distance(transform.position, targetPosition) < 0.1f)
{
// 현재 위치와 목표 위치 사이의 거리가 임계값을 초과하면 순간이동합니다.
transform.position = targetPosition;
}
else
{
// 매 프레임마다 현재 위치에서 목표 위치로 부드럽게 이동시킵니다.
transform.position = Vector3.Lerp(transform.position, targetPosition, Time.deltaTime * moveSpeed);
}
}
// 매 프레임마다 현재 회전에서 목표 회전으로 부드럽게 회전시킵니다.
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * rotationSpeed);
if (transform.rotation != targetRotation)
{
if (Quaternion.Angle(transform.rotation, targetRotation) < 0.1f)
{
transform.rotation = targetRotation;
}
else
{
// 매 프레임마다 현재 회전에서 목표 회전으로 부드럽게 회전시킵니다.
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * rotationSpeed);
}
}
}
}
}

View File

@@ -1,156 +0,0 @@
using UnityEngine;
using UnityEngine.EventSystems;
namespace UVC.Factory.Component
{
[RequireComponent(typeof(Collider))]
public class ClickTest : MonoBehaviour
{
private InputSystemActions inputSystemActions;
private Camera mainCamera;
//void Start()
//{
// Collider col = GetComponent<Collider>();
// if (col != null)
// {
// // 콜라이더의 중심과 크기 출력 (BoxCollider의 경우)
// if (col is BoxCollider boxCollider)
// {
// Debug.Log($"BoxCollider - Center: {boxCollider.center}, Size: {boxCollider.size}");
// }
// // 콜라이더의 월드 바운드 출력
// Debug.Log($"콜라이더의 월드 바운드: {col.bounds}");
// }
// else
// {
// Debug.LogError($"{gameObject.name}에 콜라이더가 없습니다!");
// }
//}
void OnMouseDown()
{
Debug.Log($"OnMouseDown: {gameObject.name}");
transform.localScale -= new Vector3(0.05f, 0.05f, 0);
//or
//transform.GetComponent<SpriteRenderer>().color += new Color(40, 40, 40);
}
void OnMouseUp()
{
Debug.Log($"OnMouseUp: {gameObject.name}");
transform.localScale += new Vector3(0.05f, 0.05f, 0);
//or
//transform.GetComponent<SpriteRenderer>().color -= new Color(40, 40, 40);
}
void Update()
{
if (UnityEngine.Input.GetMouseButtonDown(0)) // 0 represents the left mouse button
{
Ray ray = Camera.main.ScreenPointToRay(UnityEngine.Input.mousePosition);
RaycastHit hit;
// 레이 시작점과 방향 로깅
Debug.Log($"Ray 원점: {ray.origin}, 방향: {ray.direction}");
// 디버그용 레이를 씬 뷰에 그립니다
Debug.DrawRay(ray.origin, ray.direction * 100, Color.red, 60f);
bool hitSomething = Physics.Raycast(ray, out hit);
Debug.Log($"Mouse button clicked - hit something: {hitSomething}");
if (hitSomething)
{
Debug.Log($"Hit object: {hit.collider.gameObject.name}, distance: {hit.distance}, point: {hit.point}");
}
else
{
// 모든 콜라이더 로깅
Collider[] allColliders = FindObjectsByType<Collider>(FindObjectsSortMode.None);
Debug.Log($"씬에 있는 콜라이더 수: {allColliders.Length}");
foreach (var collider in allColliders)
{
if (collider.gameObject.activeInHierarchy && collider.enabled)
Debug.Log($"활성화된 콜라이더: {collider.gameObject.name}, 레이어: {LayerMask.LayerToName(collider.gameObject.layer)}");
}
}
ray = Camera.main.ScreenPointToRay(UnityEngine.Input.mousePosition);
RaycastHit[] tempinfo = new RaycastHit[100];
var hitcount = Physics.RaycastNonAlloc(ray, tempinfo, Mathf.Infinity);
Debug.Log($"Mouse button clicked hitcount:{hitcount} {Physics.Raycast(ray, out hit)}");
if (Physics.Raycast(ray, out hit))
{
// Check if the hit object has the ClickableObject script
ClickTest clickableObject = hit.collider.GetComponent<ClickTest>();
if (clickableObject != null)
{
// 수동으로 이벤트 호출
PointerEventData pointerData = new PointerEventData(EventSystem.current);
pointerData.position = UnityEngine.Input.mousePosition;
OnPointerClick(pointerData);
}
else
{
Debug.Log("Clicked on: " + hit.collider.gameObject.name + ", but it's not clickable.");
}
}
}
//if (Mouse.current.leftButton.wasPressedThisFrame)
//{
// Debug.Log("마우스 클릭 감지");
//}
}
public void OnPointerClick(PointerEventData eventData)
{
Debug.Log($"OnPointerClick1: {gameObject.name}");
}
/// <summary>
/// 포인터가 이 객체 위로 들어왔을 때 호출됩니다. 하이라이트 효과 등에 사용할 수 있습니다.
/// </summary>
/// <param name="eventData">포인터 이벤트와 관련된 데이터입니다.</param>
public void OnPointerEnter(PointerEventData eventData)
{
Debug.Log($"OnPointerEnter: {gameObject.name}");
}
/// <summary>
/// 포인터가 이 객체에서 벗어났을 때 호출됩니다.
/// </summary>
/// <param name="eventData">포인터 이벤트와 관련된 데이터입니다.</param>
public void OnPointerExit(PointerEventData eventData)
{
Debug.Log($"OnPointerExit: {gameObject.name}");
}
public void OnMouseClick()
{
Debug.Log($"OnClick: {gameObject.name}");
}
public void OnMouseEnter()
{
Debug.Log($"OnMouseEnter: {gameObject.name}");
}
public void OnMouseExit()
{
Debug.Log($"OnMouseExit: {gameObject.name}");
}
public void OnMouseWheel()
{
Debug.Log($"OnMouseWheel: {gameObject.name}");
}
}
}

View File

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

View File

@@ -61,7 +61,6 @@ namespace UVC.Factory.Component
/// <param name="eventData">포인터 클릭과 관련된 이벤트 데이터입니다.</param>
public override void OnPointerClick(PointerEventData eventData)
{
Debug.Log($"OnPointerClick2: {gameObject.name}");
if (data != null && data.Count > 0)
{
Dictionary<string, object> info = new Dictionary<string, object>();
@@ -81,7 +80,6 @@ namespace UVC.Factory.Component
// dataOrderedMask가 설정되어 있지 않으면 모든 데이터를 사용합니다.
info = new Dictionary<string, object>(data);
}
Debug.Log($"OnPointerClick: {gameObject.name} - {info.Count} items");
InfoWindow.Instance.Show(transform, info);
}
}

View File

@@ -20,25 +20,18 @@ namespace UVC.Object3d
/// 포인터로 이 객체를 클릭했을 때 호출됩니다.
/// </summary>
/// <param name="eventData">클릭 이벤트와 관련된 데이터입니다.</param>
public virtual void OnPointerClick(PointerEventData eventData)
{
Debug.Log($"OnPointerClick1: {gameObject.name}");
}
public virtual void OnPointerClick(PointerEventData eventData) {}
/// <summary>
/// 포인터가 이 객체 위로 들어왔을 때 호출됩니다. 하이라이트 효과 등에 사용할 수 있습니다.
/// </summary>
/// <param name="eventData">포인터 이벤트와 관련된 데이터입니다.</param>
public virtual void OnPointerEnter(PointerEventData eventData) {
Debug.Log($"OnPointerEnter: {gameObject.name}");
}
public virtual void OnPointerEnter(PointerEventData eventData) {}
/// <summary>
/// 포인터가 이 객체에서 벗어났을 때 호출됩니다.
/// </summary>
/// <param name="eventData">포인터 이벤트와 관련된 데이터입니다.</param>
public virtual void OnPointerExit(PointerEventData eventData) {
Debug.Log($"OnPointerExit: {gameObject.name}");
}
public virtual void OnPointerExit(PointerEventData eventData) {}
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 745da5f00db92f84cb12049e0ca9cd85
guid: 1ea984474e64af94d8fa097f80657cca
folderAsset: yes
DefaultImporter:
externalObjects: {}

View File

@@ -0,0 +1,218 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Threading;
using UnityEngine;
namespace UVC.Threading
{
/// <summary>
/// Unity 메인 스레드에서 작업을 실행하기 위한 디스패처 클래스입니다.
/// </summary>
/// <remarks>
/// 이 클래스는 싱글톤 패턴으로 구현되어 있으며, 백그라운드 스레드에서 메인 스레드(UI 스레드)로
/// 작업을 디스패치하는 기능을 제공합니다.
/// </remarks>
/// <example>
/// <code>
/// // 백그라운드 스레드에서 UI 업데이트를 해야 하는 경우 (Task 사용)
/// await Task.Run(() =>
/// {
/// // 백그라운드 작업 수행
/// var result = ComputeIntensiveTask();
///
/// // UI 업데이트는 메인 스레드에서 수행
/// MainThreadDispatcher.Instance.Enqueue(() => {
/// UpdateUIWithResult(result);
/// });
/// });
///
/// // UniTask를 사용한 비동기 작업과 메인 스레드 디스패치 예제
/// async UniTask ProcessDataInBackgroundAsync()
/// {
/// // 스레드풀에서 실행
/// await UniTask.SwitchToThreadPool();
///
/// try
/// {
/// // 백그라운드에서 시간이 걸리는 작업 수행
/// var data = await FetchDataFromServerAsync();
/// var processedData = ProcessLargeData(data);
///
/// // 결과를 메인 스레드로 전달하여 UI 업데이트
/// MainThreadDispatcher.Instance.SendToMainThread(() => {
/// // 여기에서 UI 컴포넌트를 안전하게 업데이트
/// UpdateUI(processedData);
/// ShowSuccessMessage("데이터 처리 완료");
/// });
/// }
/// catch (Exception ex)
/// {
/// // 오류 발생 시에도 메인 스레드에서 UI 관련 작업 처리
/// MainThreadDispatcher.Instance.SendToMainThread(() => {
/// ShowErrorDialog($"오류 발생: {ex.Message}");
/// Debug.LogException(ex);
/// });
/// }
/// finally
/// {
/// // 작업 완료 후 필요한 경우 다시 메인 스레드로 전환
/// await UniTask.SwitchToMainThread();
/// // 여기서부터는 다시 메인 스레드에서 실행됨
/// CleanupResources();
/// }
/// }
///
/// // MainThreadDispatcher와 UniTask를 함께 활용한 이벤트 핸들러 예제
/// public async void OnButtonClick()
/// {
/// // UI 상태 업데이트
/// loadingIndicator.SetActive(true);
///
/// try
/// {
/// // 스레드풀로 전환하여 무거운 작업 수행
/// await UniTask.SwitchToThreadPool();
///
/// // 시간이 걸리는 작업 수행
/// var result = await PerformHeavyComputationAsync();
///
/// // 결과를 UI에 반영 (메인 스레드에서)
/// MainThreadDispatcher.Instance.SendToMainThread(() => {
/// resultText.text = result.ToString();
/// loadingIndicator.SetActive(false);
/// });
/// }
/// catch (Exception ex)
/// {
/// // 예외 처리도 메인 스레드에서
/// MainThreadDispatcher.Instance.SendToMainThread(() => {
/// errorText.text = ex.Message;
/// loadingIndicator.SetActive(false);
/// });
/// }
/// }
/// </code>
/// </example>
public class MainThreadDispatcher : MonoBehaviour
{
private static MainThreadDispatcher _instance;
private static bool _instantiated;
private static SynchronizationContext _mainThreadContext;
/// <summary>
/// MainThreadDispatcher의 단일 인스턴스를 가져옵니다.
/// </summary>
/// <remarks>
/// 인스턴스가 아직 생성되지 않은 경우, 새로운 GameObject를 생성하고
/// 그 GameObject에 MainThreadDispatcher 컴포넌트를 추가합니다.
/// </remarks>
public static MainThreadDispatcher Instance
{
get
{
if (!_instantiated)
{
Initialize();
}
return _instance;
}
}
/// <summary>
/// 메인 스레드 디스패처를 초기화합니다.
/// </summary>
private static void Initialize()
{
if (!_instantiated)
{
// 이미 존재하는지 확인
_instance = FindFirstObjectByType<MainThreadDispatcher>();
// 없으면 새로 생성
if (_instance == null)
{
var go = new GameObject("MainThreadDispatcher");
_instance = go.AddComponent<MainThreadDispatcher>();
DontDestroyOnLoad(go); // 씬 전환 시에도 유지
}
_instantiated = true;
_mainThreadContext = SynchronizationContext.Current;
}
}
private readonly ConcurrentQueue<Action> _actionQueue = new ConcurrentQueue<Action>();
/// <summary>
/// 메인 스레드에서 실행할 액션을 큐에 추가합니다.
/// </summary>
/// <param name="action">메인 스레드에서 실행할 액션</param>
public void Enqueue(Action action)
{
if (action == null) return;
// 현재 스레드가 메인 스레드면 바로 실행
if (Thread.CurrentThread.ManagedThreadId == 1)
{
action.Invoke();
return;
}
// 아니면 큐에 추가하여 나중에 실행
_actionQueue.Enqueue(action);
}
/// <summary>
/// SynchronizationContext를 통해 메인 스레드에서 액션을 실행합니다.
/// </summary>
/// <param name="action">메인 스레드에서 실행할 액션</param>
public void SendToMainThread(Action action)
{
if (action == null) return;
// SynchronizationContext가 있으면 사용
if (_mainThreadContext != null)
{
_mainThreadContext.Post(_ => action(), null);
}
else
{
// SynchronizationContext가 없으면 큐에 추가
Enqueue(action);
}
}
private void Update()
{
// 큐에 있는 모든 액션 처리
while (_actionQueue.TryDequeue(out Action action))
{
try
{
action.Invoke();
}
catch (Exception e)
{
Debug.LogException(e);
}
}
}
private void Awake()
{
if (_instance == null)
{
_instance = this;
_mainThreadContext = SynchronizationContext.Current;
_instantiated = true;
DontDestroyOnLoad(gameObject);
}
else if (_instance != this)
{
Destroy(gameObject);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 116a97c0eb67f214388de1a4c61c6046

View File

@@ -15,9 +15,13 @@ namespace UVC.UI.Info
public class InfoWindow : SingletonScene<InfoWindow>
{
[Tooltip("정보 텍스트를 표시할 UI 요소")]
[Tooltip("Label 정보 텍스트를 표시할 UI 요소")]
[SerializeField]
private TextMeshProUGUI infoText;
private TextMeshProUGUI labelText;
[Tooltip("Value 정보 텍스트를 표시할 UI 요소")]
[SerializeField]
private TextMeshProUGUI valueText;
[Tooltip("정보 창을 닫을 버튼")]
[SerializeField]
@@ -25,7 +29,7 @@ namespace UVC.UI.Info
[Tooltip("UI가 객체를 가리지 않도록 할 월드 좌표계 오프셋")]
[SerializeField]
private Vector3 worldOffset = new Vector3(0, 1.5f, 0);
private Vector2 screenOffset = new Vector2(0f, 0f);
// 정보 창이 따라다닐 3D 객체의 Transform
private Transform? target;
@@ -47,6 +51,9 @@ namespace UVC.UI.Info
closeButton.onClick.AddListener(Hide);
}
RectTransform? rectTransform = transform as RectTransform;
if (rectTransform != null && screenOffset == Vector2.zero) screenOffset = new Vector2(rectTransform.rect.width / 2 + 10f, - rectTransform.rect.height / 2);
// 처음에는 정보 창을 숨깁니다.
if (gameObject.activeSelf)
{
@@ -57,10 +64,73 @@ namespace UVC.UI.Info
private void LateUpdate()
{
// target이 설정되어 있고 활성화 상태일 때만 위치와 방향을 업데이트합니다.
if (target != null && gameObject.activeSelf)
if (target != null && gameObject.activeSelf && Camera.main != null)
{
// 위치 업데이트
transform.position = target.position + worldOffset;
// 타겟의 렌더러 또는 콜라이더를 가져와 화면 상의 크기를 계산
Bounds bounds = new Bounds(target.position, Vector3.one);
Renderer? renderer = target.GetComponent<Renderer>();
Collider? collider = target.GetComponent<Collider>();
// 렌더러가 있으면 렌더러의 바운드를, 없으면 콜라이더의 바운드를 사용
if (renderer != null)
{
bounds = renderer.bounds;
}
else if (collider != null)
{
bounds = collider.bounds;
}
// 바운드의 오른쪽 지점을 월드 좌표로 계산
Vector3 rightPoint = bounds.center + new Vector3(bounds.extents.x, 0, 0);
// 바운드의 오른쪽 지점을 스크린 좌표로 변환
Vector3 screenPosRight = Camera.main.WorldToScreenPoint(rightPoint);
// 추가 오프셋 적용
screenPosRight.x += screenOffset.x;
screenPosRight.y += screenOffset.y;
// 메뉴바 영역(상단 70픽셀) 고려 및 화면 밖으로 나가지 않도록 제한
float menuBarHeight = 70f;
screenPosRight.x = Mathf.Clamp(screenPosRight.x, 100f, Screen.width - 100f);
screenPosRight.y = Mathf.Clamp(screenPosRight.y, 100f, Screen.height - menuBarHeight);
// RectTransform을 사용하여 UI 위치 설정
RectTransform? rectTransform = transform as RectTransform;
if (rectTransform != null)
{
// 캔버스의 렌더링 모드에 따라 다르게 처리
Canvas canvas = GetComponentInParent<Canvas>();
if (canvas != null)
{
if (canvas.renderMode == RenderMode.ScreenSpaceOverlay)
{
rectTransform.position = screenPosRight;
}
else if (canvas.renderMode == RenderMode.ScreenSpaceCamera ||
canvas.renderMode == RenderMode.WorldSpace)
{
// 스크린 좌표를 캔버스 상의 로컬 좌표로 변환
Vector2 localPoint;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvas.GetComponent<RectTransform>(),
screenPosRight,
canvas.renderMode == RenderMode.ScreenSpaceCamera ? canvas.worldCamera : Camera.main,
out localPoint);
rectTransform.localPosition = new Vector3(localPoint.x, localPoint.y, rectTransform.localPosition.z);
}
}
}
// UI가 항상 보이도록 카메라를 향하게 설정 (World Space Canvas인 경우에만 필요)
Canvas parentCanvas = GetComponentInParent<Canvas>();
if (parentCanvas != null && parentCanvas.renderMode == RenderMode.WorldSpace)
{
transform.LookAt(transform.position + Camera.main.transform.rotation * Vector3.forward,
Camera.main.transform.rotation * Vector3.up);
}
}
}
@@ -72,10 +142,29 @@ namespace UVC.UI.Info
public void Show(Transform targetObject, Dictionary<string, object> information)
{
target = targetObject;
if (infoText != null)
if (labelText != null)
{
infoText.text = information.ToString();
string labelString = string.Empty;
string valueString = string.Empty;
foreach (var kvp in information)
{
labelString += $"{kvp.Key}\n";
valueString += $"{kvp.Value ?? "null"}\n";
}
labelString = labelString.TrimEnd('\n'); // 마지막 줄바꿈 제거
valueString = valueString.TrimEnd('\n'); // 마지막 줄바꿈 제거
Debug.Log($"InfoWindow: {labelString}, {valueString}");
labelText.text = labelString;
valueText.text = valueString;
}
//size를 text에 맞게 조정합니다.
RectTransform? rect = GetComponent<RectTransform>();
if (rect != null)
{
rect.sizeDelta = new Vector2(rect.rect.width, valueText.preferredHeight + 25f);
}
gameObject.SetActive(true);
// 즉시 위치와 방향을 업데이트합니다.

View File

@@ -4,7 +4,7 @@ using System; // System.Type 사용을 위해 추가
using System.Threading;
using UnityEngine;
using UVC.Log;
using UVC.util;
using UVC.Util;
namespace UVC.UI.Modal
{

View File

@@ -7,7 +7,7 @@ using UVC.Extension;
using UVC.Locale;
using UVC.UI.Toolbar.Model;
using UVC.UI.Tooltip;
using UVC.util;
using UVC.Util;
namespace UVC.UI.Toolbar.View
{

View File

@@ -2,7 +2,7 @@
using TMPro;
using UnityEngine;
using UVC.Locale;
using UVC.util; // LocalizationManager를 사용한다면 필요합니다.
using UVC.Util; // LocalizationManager를 사용한다면 필요합니다.
namespace UVC.UI.Tooltip
{

View File

@@ -0,0 +1,244 @@
using System;
using System.Collections;
using UnityEngine;
using UVC.Core;
namespace UVC.Util
{
/// <summary>
/// 마우스 입력에 따라 카메라를 이동, 회전, 줌하는 컨트롤러입니다.
/// - 마우스 가운데 버튼 드래그: 카메라 평행 이동 (Pan)
/// - 마우스 오른쪽 버튼 드래그: 카메라 회전 (Orbit)
/// - 마우스 휠 스크롤: 카메라 줌 (Zoom)
/// </summary>
public class CameraController : SingletonScene<CameraController>
{
[Tooltip("카메라 평행 이동 속도")]
public float panSpeed = 20f;
[Tooltip("카메라 회전 속도")]
public float rotationSpeed = 300f;
[Tooltip("카메라 줌 속도")]
public float zoomSpeed = 10f;
[Header("Movement Smoothing")]
[Tooltip("패닝 시 마우스 이동량의 최대값을 제한하여, 프레임 드랍 시 카메라가 급격하게 튀는 현상을 방지합니다.")]
public float maxPanDelta = 50f;
private Vector3 lastPanPosition;
private Vector3 rotationPivot;
private bool isRotating = false;
void Start()
{
// 스크립트 시작 시, 회전의 기준이 되는 중심점을 카메라 앞쪽으로 초기화합니다.
rotationPivot = transform.position + transform.forward * 10f;
}
// Update 대신 LateUpdate를 사용하여 카메라 움직임이 다른 모든 업데이트 이후에 처리되도록 합니다.
// 이를 통해 카메라의 떨림이나 끊김 현상을 줄일 수 있습니다.
void LateUpdate()
{
HandlePanning();
HandleRotation();
HandleZoom();
}
/// <summary>
/// 마우스 가운데 버튼으로 카메라를 평행 이동시킵니다.
/// 프레임 지연으로 인한 급격한 이동을 방지하기 위해 이동량을 제한합니다.
/// </summary>
private void HandlePanning()
{
if (Input.GetMouseButtonDown(2))
{
lastPanPosition = Input.mousePosition;
}
if (Input.GetMouseButton(2))
{
Vector3 delta = Input.mousePosition - lastPanPosition;
// 프레임 드랍 시 델타 값이 너무 커져서 카메라가 튀는 것을 방지하기 위해 최대값을 제한합니다.
if (delta.magnitude > maxPanDelta)
{
delta = delta.normalized * maxPanDelta;
}
// 카메라의 로컬 좌표계를 기준으로 이동량을 변환하여 월드 좌표계에서 이동시킵니다.
transform.Translate(transform.right * -delta.x * panSpeed * Time.deltaTime, Space.World);
transform.Translate(transform.up * -delta.y * panSpeed * Time.deltaTime, Space.World);
lastPanPosition = Input.mousePosition;
}
}
/// <summary>
/// 마우스 오른쪽 버튼으로 카메라를 회전시킵니다.
/// 회전 축이 변하는 것을 방지하여 안정적인 회전을 구현합니다.
/// </summary>
private void HandleRotation()
{
if (Input.GetMouseButtonDown(1))
{
isRotating = true;
// 마우스 클릭 지점으로 Ray를 쏴서 회전의 중심점(pivot)을 설정합니다.
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out RaycastHit hit, 1000f))
{
rotationPivot = hit.point;
}
else
{
// Ray가 아무 오브젝트에도 맞지 않았다면, 카메라 앞쪽의 특정 거리를 중심점으로 사용합니다.
rotationPivot = transform.position + transform.forward * 10f;
}
}
if (Input.GetMouseButtonUp(1))
{
isRotating = false;
}
if (isRotating && Input.GetMouseButton(1))
{
float mouseX = Input.GetAxis("Mouse X") * rotationSpeed * Time.deltaTime;
float mouseY = Input.GetAxis("Mouse Y") * rotationSpeed * Time.deltaTime;
// 수평 회전으로 인해 수직 회전 축(transform.right)이 변질되는 것을 방지하기 위해
// 회전 전의 right 벡터를 미리 저장해 둡니다.
Vector3 verticalRotationAxis = transform.right;
// 설정된 중심점을 기준으로 카메라를 회전시킵니다.
// 1. 수평 회전 (월드 Y축 기준)
transform.RotateAround(rotationPivot, Vector3.up, -mouseX);
// 2. 수직 회전 (미리 저장해 둔 카메라의 오른쪽 축 기준)
transform.RotateAround(rotationPivot, verticalRotationAxis, mouseY);
}
}
/// <summary>
/// 마우스 휠로 카메라를 줌 인/아웃합니다.
/// </summary>
private void HandleZoom()
{
float scroll = Input.GetAxis("Mouse ScrollWheel");
if (scroll != 0f)
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
Vector3 zoomTarget;
if (Physics.Raycast(ray, out RaycastHit hit))
{
zoomTarget = hit.point;
}
else
{
zoomTarget = ray.GetPoint(1000);
}
Vector3 direction = zoomTarget - transform.position;
// 줌 실행
transform.position += direction.normalized * scroll * zoomSpeed;
}
}
/// <summary>
/// 지정된 Transform을 중심으로 카메라를 포커싱합니다.
/// </summary>
/// <param name="equipmentTransform">포커스할 대상의 Transform</param>
/// <param name="distance">대상과의 거리</param>
public void FocusOnTargetFast(Transform equipmentTransform, float distance)
{
if (equipmentTransform == null)
return;
// 카메라가 바라볼 대상의 중심점
Vector3 targetPosition = equipmentTransform.position;
// 현재 카메라의 회전각을 유지하면서 타겟을 바라보는 방향 설정
Vector3 directionToTarget = (targetPosition - transform.position).normalized;
// 타겟으로부터 지정된 거리만큼 떨어진 위치 계산
Vector3 newPosition = targetPosition - directionToTarget * distance;
// 카메라 위치 설정 및 타겟을 바라보도록 함
transform.position = newPosition;
transform.LookAt(targetPosition);
// 회전 피봇 포인트 업데이트
rotationPivot = targetPosition;
}
/// <summary>
/// 지정된 Transform을 중심으로 카메라를 포커싱합니다.
/// </summary>
/// <param name="equipmentTransform">포커스할 대상의 Transform</param>
/// <param name="distance">대상과의 거리</param>
/// <param name="duration">이동에 걸리는 시간(초), 기본값 1초</param>
public void FocusOnTarget(Transform equipmentTransform, float distance, float duration = 1.0f)
{
if (equipmentTransform == null)
return;
// 코루틴을 사용하여 부드러운 이동 구현
StartCoroutine(SmoothFocusOnTarget(equipmentTransform, distance, duration));
}
/// <summary>
/// 부드럽게 타겟까지 이동하는 코루틴
/// </summary>
private IEnumerator SmoothFocusOnTarget(Transform targetTransform, float distance, float duration)
{
// 카메라가 바라볼 대상의 중심점
Vector3 targetPosition = targetTransform.position;
// 시작 위치와 회전 저장
Vector3 startPosition = transform.position;
Quaternion startRotation = transform.rotation;
// 타겟을 보는 최종 위치와 회전 계산
Vector3 directionToTarget = (targetPosition - startPosition).normalized;
Vector3 endPosition = targetPosition - directionToTarget * distance;
// 최종 회전값 계산
Quaternion endRotation = Quaternion.LookRotation(targetPosition - endPosition);
// 이동 시간 계산을 위한 변수
float elapsedTime = 0f;
while (elapsedTime < duration)
{
elapsedTime += Time.deltaTime;
float t = Mathf.Clamp01(elapsedTime / duration);
// 부드러운 이동을 위한 Easing 함수 적용
float smoothT = EaseInOutCubic(t);
// 위치와 회전 보간
transform.position = Vector3.Lerp(startPosition, endPosition, smoothT);
transform.rotation = Quaternion.Slerp(startRotation, endRotation, smoothT);
yield return null;
}
// 정확한 최종 위치와 회전 설정
transform.position = endPosition;
transform.LookAt(targetPosition);
// 회전 피봇 포인트 업데이트
rotationPivot = targetPosition;
}
/// <summary>
/// Cubic ease-in/out 함수로 부드러운 이동 효과를 줍니다.
/// </summary>
private float EaseInOutCubic(float t)
{
return t < 0.5f ? 4f * t * t * t : 1f - Mathf.Pow(-2f * t + 2f, 3f) / 2f;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 61deddb674c074049a9b43fd58f1b355

View File

@@ -1,58 +0,0 @@
using UnityEngine;
namespace UVC.input
{
public class InputHandler : MonoBehaviour
{
public Transform clickedObject;
// 프레임당 한 번씩 업데이트가 호출됩니다
void Update()
{
// 터치 입력 확인
if (UnityEngine.Input.touchCount > 0)
{
if (UnityEngine.Input.GetTouch(0).phase == TouchPhase.Began)
{
handleClick(UnityEngine.Input.GetTouch(0).position);
}
}
// 왼쪽 마우스 버튼 클릭 확인
if (UnityEngine.Input.GetMouseButtonDown(0))
{
handleClick(UnityEngine.Input.mousePosition);
}
}
void handleClick(Vector3 screenClickPosition)
{
// 클릭한 위치에서 카메라에 수직인 Ray를 그립니다
// Ray가 교차하는 첫 번째 객체를 캡처하기 위해 RayHit를 생성합니다
Ray ray = Camera.main.ScreenPointToRay(screenClickPosition);
RaycastHit rayHit;
if (Physics.Raycast(ray.origin, ray.direction, out rayHit))
{
Debug.Log($"Ray Origin: {ray.origin}, Direction: {ray.direction}");
if (rayHit.transform.tag == "Clickable")
{
// 클릭한 객체에 어떤 작업을 합니다.
clickedObject = rayHit.transform;
// 다음 클릭을 위해 ray와 rayHit을 재설정합니다.
ray = new Ray();
rayHit = new RaycastHit();
}
else if (clickedObject != null)
{
// 클릭한 객체의 y 위치에서 xz 축에 평면을 그립니다.
Plane xzPlane = new Plane(Vector3.up, clickedObject.position);
float distance; // 클릭과 평면이 교차 하는 지점을 찾습니다.
// 그리고 클릭한 객체를 새로운 위치로 이동합니다.
if (xzPlane.Raycast(ray, out distance))
{
clickedObject.position = ray.GetPoint(distance);
}
}
}
}
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 8de502d6dd9c6114f993b104854864c5

View File

@@ -1,79 +0,0 @@
using UnityEngine;
using UnityEngine.InputSystem;
namespace UVC.input
{
public class MouseInputHandler : MonoBehaviour
{
private MouseControls mouseControls;
private Camera mainCamera;
private GameObject lastHoveredObject = null;
private void Awake()
{
mainCamera = Camera.main;
mouseControls = new MouseControls();
}
private void OnEnable()
{
mouseControls.Player.Enable();
mouseControls.Player.Click.performed += OnClick;
}
private void OnDisable()
{
mouseControls.Player.Disable();
mouseControls.Player.Click.performed -= OnClick;
}
void Update()
{
// 마우스 위치로 Ray 발사
Ray ray = mainCamera.ScreenPointToRay(mouseControls.Player.Point.ReadValue<Vector2>());
RaycastHit hit;
GameObject currentHoveredObject = null;
if (Physics.Raycast(ray, out hit))
{
// Ray에 맞은 객체가 있을 경우
currentHoveredObject = hit.collider.gameObject;
}
// 마우스 오버/아웃 상태 변경 감지
if (currentHoveredObject != lastHoveredObject)
{
// 이전에 가리키던 객체가 있었다면 Mouse Out 이벤트 호출
if (lastHoveredObject != null)
{
// 여기에 Mouse Out 로직을 추가하세요.
Debug.Log("Mouse Out: " + lastHoveredObject.name);
// 예: lastHoveredObject.GetComponent<Renderer>().material.color = Color.white;
}
// 현재 가리키는 객체가 있다면 Mouse Over 이벤트 호출
if (currentHoveredObject != null)
{
// 여기에 Mouse Over 로직을 추가하세요.
Debug.Log("Mouse Over: " + currentHoveredObject.name);
// 예: currentHoveredObject.GetComponent<Renderer>().material.color = Color.red;
}
lastHoveredObject = currentHoveredObject;
}
}
private void OnClick(InputAction.CallbackContext context)
{
Debug.Log("Clicked");
// 현재 마우스가 가리키고 있는 객체가 있을 때만 클릭 이벤트 처리
if (lastHoveredObject != null)
{
// 여기에 Mouse Click 로직을 추가하세요.
Debug.Log("Clicked on: " + lastHoveredObject.name);
}
}
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 72b9b338934540349a2d195547344dab

View File

@@ -2,7 +2,7 @@
using UnityEngine;
using UnityEngine.UI;
namespace UVC.util
namespace UVC.Util
{
public static class CanvasUtil
{