TwinAgentAutoProcessor 가중치 완료조건 및 Entity별 터미널 로그 분리

- CompletionResult enum 추가 (Complete/Warning/Error)로 완료 상태 분류
- WeightedCompletion으로 SubStep별 가중치 기반 랜덤 완료 조건 구현
  - Server: OctopusHub 75% / NotFound 25%(Error, 중지)
  - Protocol: API 75% / MQTT 25%
  - ServerStatusCheck: Done 85% / Busy 10%(Warning, 중지) / Error 5%(중지)
  - Speed: 5~120ms 80% / "300ms~" 20%
- IsFullPipelineCompleted 속성으로 전체 파이프라인 완료 여부 판별
- MessageHistory 버퍼로 Entity별 터미널 로그 이력 관리
- Entity 전환 시 ClearAllProcessorScopedBindings()로 이전 UI 바인딩 해제
- EntityPropertyAdapter에서 PropertyItem 복원과 터미널 복원 순서 분리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
wsh
2026-02-10 15:58:31 +09:00
parent 9e9314d32d
commit f840158bbf
9 changed files with 4186 additions and 734 deletions

View File

@@ -1,14 +1,13 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine;
namespace UVC.Entity
{
/// <summary>
/// Scene 내의 물리적 객체를 나타내는 베이스 클래스입니다.
/// 순수한 데이터 클래스로, UI와 직접적인 의존성이 없습니다.
/// PropertyWindow 연동은 EntityPropertyAdapter를 통해 이루어집니다.
/// 프로세서 컨테이너 역할을 수행합니다.
/// Scene 상에 물리적으로 존재하는 객체와 연동된 데이터 클래스 입니다.
/// UI와 직접적인 의존성을 가지면 안됩니다.
/// UI와의 연동은 Adapter 패턴을 사용하여 수행합니다.
/// </summary>
public abstract class Entity
{
@@ -20,18 +19,18 @@ namespace UVC.Entity
/// <summary>
/// 엔티티와 연결된 GameObject
/// </summary>
public abstract GameObject GameObject { get; set; }
public abstract GameObject gameObject { get; set; }
/// <summary>
/// 엔티티의 이름
/// </summary>
public virtual string Name
{
get => GameObject != null ? GameObject.name : "Unknown";
get => gameObject != null ? gameObject.name : "Unknown";
set
{
if (GameObject != null)
GameObject.name = value;
if (gameObject != null)
gameObject.name = value;
}
}
@@ -40,6 +39,11 @@ namespace UVC.Entity
/// </summary>
private readonly Dictionary<string, EntityProcessor> _processors = new Dictionary<string, EntityProcessor>();
/// <summary>
/// 프로세서가 등록될 때 발생합니다.
/// </summary>
public event Action<EntityProcessor> OnProcessorRegistered;
/// <summary>
/// 프로세서를 등록합니다.
/// </summary>
@@ -60,6 +64,7 @@ namespace UVC.Entity
_processors[processor.ProcessorId] = processor;
Debug.Log($"[Entity] 프로세서 등록: {processor.ProcessorId}");
OnProcessorRegistered?.Invoke(processor);
}
public bool TryGetProcessor<T>(out T processor) where T : EntityProcessor
@@ -76,6 +81,19 @@ namespace UVC.Entity
return false;
}
/// <summary>
/// 모든 프로세서의 스코프 바인딩을 해제합니다.
/// Entity 전환 시 이전 Entity의 UI 핸들러를 정리하기 위해 사용합니다.
/// 프로세서 자체는 백그라운드에서 계속 실행됩니다.
/// </summary>
public void ClearAllProcessorScopedBindings()
{
foreach (var processor in _processors.Values)
{
processor.ClearScopedBindings();
}
}
/// <summary>
/// 프로세서를 제거합니다.
/// </summary>

View File

@@ -6,6 +6,259 @@ using UnityEngine;
namespace UVC.Entity.Processors
{
// ═══════════════════════════════════════════════════════════════
// Enums
// ═══════════════════════════════════════════════════════════════
/// <summary>Auto 프로세스 MainStep (실행 순서대로 정의)</summary>
public enum AutoStep
{
GetEntityInfo = 0,
ExtractNetworkInfo = 1,
Connecting = 2,
Server = 3,
Port = 4,
Protocol = 5,
ServerStatusCheck = 6,
Speed = 7,
}
/// <summary>MainStep 내의 개별 진행 단계 식별자</summary>
public enum SubStepId
{
// ── GetEntityInfo ──
ReadEntityMetaData,
ReadEntityDefaultData,
ReadEntityDetailInfo,
ReadEntityStatus,
Coalescing,
GenerateGeneralInfo,
// ── ExtractNetworkInfo (일부 공유) ──
Processing,
Perusing,
Inferring,
// ── Connecting ──
Crunching,
Sussing,
// ── Server ──
Spelunking,
GenerateServerList,
Forming,
TargetServer,
// ── Port ──
Scanning,
PortNumber,
// ── Protocol ──
Shimmying,
Transmuting,
ProtocolType,
// ── ServerStatusCheck ──
ServerStatus,
// ── Speed ──
Speed,
}
/// <summary>MainStep 완료 상태 식별자</summary>
public enum CompletionId
{
Done,
Busy,
Canceled,
NotFound,
OctopusHub,
API,
MQTT,
Error,
}
/// <summary>완료 상태의 결과 분류 (UI 색상 매핑에 사용)</summary>
public enum CompletionResult
{
/// <summary>정상 완료 (프로세스 계속)</summary>
Complete,
/// <summary>경고 (프로세스 중지)</summary>
Warning,
/// <summary>에러 (프로세스 중지)</summary>
Error,
}
// ═══════════════════════════════════════════════════════════════
// String 매핑 (단일 소스)
// ═══════════════════════════════════════════════════════════════
/// <summary>
/// 모든 enum → 문자열 매핑을 관리합니다.
/// SendMessage로 전달되는 문자열은 이 클래스에서만 정의합니다.
/// </summary>
public static class StepConfig
{
private static readonly Dictionary<AutoStep, string> PropertyIds = new()
{
[AutoStep.GetEntityInfo] = "get_entity_info_status",
[AutoStep.ExtractNetworkInfo] = "extract_network_info_status",
[AutoStep.Connecting] = "connecting_status",
[AutoStep.Server] = "server_status",
[AutoStep.Port] = "port_status",
[AutoStep.Protocol] = "protocol_status",
[AutoStep.ServerStatusCheck] = "server_status_check",
[AutoStep.Speed] = "speed_status",
};
private static readonly Dictionary<SubStepId, string> SubStepTexts = new()
{
[SubStepId.ReadEntityMetaData] = "Read Entity Meta Data",
[SubStepId.ReadEntityDefaultData] = "Read Entity Default Data",
[SubStepId.ReadEntityDetailInfo] = "Read Entity Detail Info",
[SubStepId.ReadEntityStatus] = "Read Entity Status",
[SubStepId.Coalescing] = "Coalescing",
[SubStepId.GenerateGeneralInfo] = "Generate General Info",
[SubStepId.Processing] = "Processing",
[SubStepId.Perusing] = "Perusing",
[SubStepId.Inferring] = "Inferring",
[SubStepId.Crunching] = "Crunching",
[SubStepId.Sussing] = "Sussing",
[SubStepId.Spelunking] = "Spelunking",
[SubStepId.GenerateServerList] = "Generate Server List",
[SubStepId.Forming] = "Forming",
[SubStepId.TargetServer] = "Target Server",
[SubStepId.Scanning] = "Scanning",
[SubStepId.PortNumber] = "Port Number",
[SubStepId.Shimmying] = "Shimmying",
[SubStepId.Transmuting] = "Transmuting",
[SubStepId.ProtocolType] = "Protocol Type",
[SubStepId.ServerStatus] = "ServerStatus",
[SubStepId.Speed] = "Speed",
};
public static string GetPropertyId(AutoStep step) => PropertyIds[step];
public static string GetSubStepText(SubStepId id) => SubStepTexts[id];
public static string GetCompletionText(CompletionId id) => id.ToString();
}
// ═══════════════════════════════════════════════════════════════
// Definition Types
// ═══════════════════════════════════════════════════════════════
/// <summary>가중치가 포함된 완료 상태</summary>
public readonly struct WeightedCompletion
{
public readonly CompletionId Id;
public readonly float Weight;
public readonly CompletionResult Result;
private readonly Func<string> _textOverride;
/// <summary>프로세스 중지 여부 (Complete가 아니면 중지)</summary>
public bool StopsProcess => Result != CompletionResult.Complete;
public WeightedCompletion(CompletionId id, float weight = 1f,
CompletionResult result = CompletionResult.Complete)
{
Id = id;
Weight = weight;
Result = result;
_textOverride = null;
}
/// <summary>동적 텍스트를 생성하는 완료 상태 (예: 랜덤 속도값)</summary>
public WeightedCompletion(float weight, Func<string> textGenerator,
CompletionResult result = CompletionResult.Complete)
{
Id = CompletionId.Done;
Weight = weight;
Result = result;
_textOverride = textGenerator;
}
public string Status => _textOverride?.Invoke() ?? StepConfig.GetCompletionText(Id);
}
/// <summary>
/// MainStep 정의. SubStep 순서와 완료 조건을 포함합니다.
/// </summary>
public class StepDefinition
{
public AutoStep Step { get; }
public string PropertyId { get; }
public SubStepId[] SubSteps { get; }
public WeightedCompletion[] Completions { get; }
/// <summary>전체 카운트 (SubSteps + completion)</summary>
public int TotalCount => SubSteps.Length + 1;
public StepDefinition(AutoStep step, SubStepId[] subSteps, params WeightedCompletion[] completions)
{
Step = step;
PropertyId = StepConfig.GetPropertyId(step);
SubSteps = subSteps;
Completions = completions.Length > 0
? completions
: new[] { new WeightedCompletion(CompletionId.Done) };
}
/// <summary>가중치 기반으로 완료 상태를 선택합니다.</summary>
public WeightedCompletion SelectCompletion()
{
if (Completions.Length == 1) return Completions[0];
float totalWeight = 0f;
foreach (var c in Completions) totalWeight += c.Weight;
float random = UnityEngine.Random.Range(0f, totalWeight);
float cumulative = 0f;
foreach (var c in Completions)
{
cumulative += c.Weight;
if (random <= cumulative) return c;
}
return Completions[^1];
}
}
/// <summary>SubStep 변경 시 전달되는 진행 정보</summary>
public readonly struct StepProgressInfo
{
/// <summary>현재 MainStep</summary>
public readonly AutoStep Step;
/// <summary>MainStep의 PropertyId</summary>
public readonly string PropertyId;
/// <summary>현재 상태 문자열</summary>
public readonly string Status;
/// <summary>현재 인덱스 (0-based)</summary>
public readonly int CurrentIndex;
/// <summary>전체 수 (SubSteps + completion)</summary>
public readonly int TotalCount;
/// <summary>MainStep 완료 여부</summary>
public readonly bool IsCompleted;
/// <summary>완료 결과 분류 (SubStep 진행 중이면 null)</summary>
public readonly CompletionResult? Result;
/// <summary>진행률 (0.0 ~ 1.0)</summary>
public float Progress => TotalCount > 0 ? (float)(CurrentIndex + 1) / TotalCount : 0f;
public StepProgressInfo(AutoStep step, string propertyId, string status,
int currentIndex, int totalCount, bool isCompleted, CompletionResult? result = null)
{
Step = step;
PropertyId = propertyId;
Status = status;
CurrentIndex = currentIndex;
TotalCount = totalCount;
IsCompleted = isCompleted;
Result = result;
}
}
// ═══════════════════════════════════════════════════════════════
// Processor
// ═══════════════════════════════════════════════════════════════
/// <summary>
/// Twin Agent의 자동 설정 프로세서입니다.
/// Auto 버튼 클릭 시 순차적으로 네트워크 설정을 진행합니다.
@@ -17,14 +270,121 @@ namespace UVC.Entity.Processors
private readonly Entity _entity;
/// <summary>완료된 단계 상태 (PropertyId -> Value). 복원용.</summary>
private readonly Dictionary<string, string> _stepStates = new Dictionary<string, string>();
/// <summary>완료된 단계 상태 (PropertyId -> Value). PropertyItem 복원용.</summary>
private readonly Dictionary<string, string> _stepStates = new();
/// <summary>전체 메시지 이력 (Entity별 터미널 로그 복원용)</summary>
private readonly List<(string PropertyId, string Value)> _messageHistory = new();
/// <summary>현재 진행 중인 PropertyId (취소 시 사용)</summary>
private string _currentPropertyId;
public bool HasSavedState => _stepStates.Count > 0;
/// <summary>전체 메시지 이력 (읽기 전용)</summary>
public IReadOnlyList<(string PropertyId, string Value)> MessageHistory => _messageHistory;
/// <summary>전체 파이프라인이 중지 없이 완료되었는지 여부 (Speed까지 완료해야 true)</summary>
public bool IsFullPipelineCompleted { get; private set; }
/// <summary>SubStep 진행 시 콜백 (진행도 정보 포함)</summary>
public event Action<StepProgressInfo> onSubStepChanged;
/// <summary>MainStep의 마지막 SubStep 진입 시 콜백</summary>
public event Action<StepProgressInfo> onLastSubStepEntered;
/// <summary>MainStep 완료 시 콜백 (step, completionStatus, result)</summary>
public event Action<AutoStep, string, CompletionResult> onStepCompleted;
#region Step Definitions
// ─────────────────────────────────────────────────────────
// 파이프라인 정의: MainStep → [ SubSteps... ] → Completion
// 매직 스트링 없음. 순서 = 배열 인덱스.
// ─────────────────────────────────────────────────────────
private static readonly StepDefinition[] StepDefinitions =
{
// 0. Entity 정보 수집
new(AutoStep.GetEntityInfo, new[]
{
SubStepId.ReadEntityMetaData,
SubStepId.ReadEntityDefaultData,
SubStepId.ReadEntityDetailInfo,
SubStepId.ReadEntityStatus,
SubStepId.Coalescing,
SubStepId.GenerateGeneralInfo,
}),
// 1. 네트워크 정보 추출
new(AutoStep.ExtractNetworkInfo, new[]
{
SubStepId.Processing,
SubStepId.Perusing,
SubStepId.Inferring,
}),
// 2. 연결
new(AutoStep.Connecting, new[]
{
SubStepId.Crunching,
SubStepId.Sussing,
}),
// 3. 서버 탐색 (OctopusHub 75% / NotFound 25%)
new(AutoStep.Server, new[]
{
SubStepId.Spelunking,
SubStepId.Inferring,
SubStepId.GenerateServerList,
SubStepId.Forming,
SubStepId.TargetServer,
},
new WeightedCompletion(CompletionId.OctopusHub, 0.75f),
new WeightedCompletion(CompletionId.NotFound, 0.25f, CompletionResult.Error)
),
// 4. 포트 스캔
new(AutoStep.Port, new[]
{
SubStepId.Scanning,
SubStepId.PortNumber,
}),
// 5. 프로토콜 결정 (API 75% / MQTT 25%)
new(AutoStep.Protocol, new[]
{
SubStepId.Shimmying,
SubStepId.Transmuting,
SubStepId.Perusing,
SubStepId.ProtocolType,
},
new WeightedCompletion(CompletionId.API, 0.75f),
new WeightedCompletion(CompletionId.MQTT, 0.25f)
),
// 6. 서버 상태 확인 (Done 85% / Busy 10% / Error 5%)
new(AutoStep.ServerStatusCheck, new[]
{
SubStepId.ServerStatus,
},
new WeightedCompletion(CompletionId.Done, 0.85f),
new WeightedCompletion(CompletionId.Busy, 0.10f, CompletionResult.Warning),
new WeightedCompletion(CompletionId.Error, 0.05f, CompletionResult.Error)
),
// 7. 속도 측정 (5~120ms 80% / "300ms~" 20%)
new(AutoStep.Speed, new[]
{
SubStepId.Speed,
},
new WeightedCompletion(0.80f, () => $"{UnityEngine.Random.Range(5, 121)}ms"),
new WeightedCompletion(0.20f, () => "300ms~")
),
};
#endregion
public TwinAgentAutoProcessor(Entity entity, Action onInit = null) : base(onInit)
{
_entity = entity;
@@ -47,19 +407,31 @@ namespace UVC.Entity.Processors
protected override async UniTask Processing(CancellationToken ct)
{
Debug.Log("[TwinAgentAutoProcessor] Auto 프로세스 시작");
IsFullPipelineCompleted = false;
await RunReadEntity(ct);
await RunConnection(ct);
foreach (var def in StepDefinitions)
{
Debug.Log($"[TwinAgentAutoProcessor] Step {(int)def.Step}: {def.Step}");
bool shouldContinue = await RunStep(def, ct);
if (!shouldContinue)
{
Debug.Log($"[TwinAgentAutoProcessor] 프로세스 중지 ({def.Step})");
return;
}
}
Debug.Log("[TwinAgentAutoProcessor] Auto 프로세스 완료");
IsFullPipelineCompleted = true;
Debug.Log("[TwinAgentAutoProcessor] Auto 프로세스 완료 (전체 파이프라인 완료)");
}
protected override void ProcessCancel()
{
if (_currentPropertyId != null)
{
SendMessage(_currentPropertyId, "Canceled");
_stepStates[_currentPropertyId] = "Canceled";
var canceledText = StepConfig.GetCompletionText(CompletionId.Canceled);
SendMessage(_currentPropertyId, canceledText);
_messageHistory.Add((_currentPropertyId, canceledText));
_stepStates[_currentPropertyId] = canceledText;
Debug.Log($"[TwinAgentAutoProcessor] 취소 (항목: {_currentPropertyId})");
}
}
@@ -67,7 +439,9 @@ namespace UVC.Entity.Processors
protected override void ProcessReset()
{
_stepStates.Clear();
_messageHistory.Clear();
_currentPropertyId = null;
IsFullPipelineCompleted = false;
Debug.Log("[TwinAgentAutoProcessor] 초기화 완료");
}
@@ -78,76 +452,50 @@ namespace UVC.Entity.Processors
return "Ready";
}
#region 0. Read Entity
private async UniTask RunReadEntity(CancellationToken ct)
{
await RunStep("get_entity_info_status",
new[] {
"Read Entity Meta Data",
"Read Entity Default Data",
"Read Entity Detail Info",
"Read Entity Status",
"Coalescing",
"Generate General Info",
"Done" }, ct);
await RunStep("extract_network_info_status",
new[] {
"Processing",
"Perusing",
"Inferring",
"Done" }, ct);
await RunStep("connecting_status",
new[] {
"Crunching",
"Sussing",
"Done" }, ct);
}
#endregion
#region 1. Connection
private async UniTask RunConnection(CancellationToken ct)
{
await RunStep("server_status",
new[] { "Spelunking", "Inferring", "Generate Server List", "Forming", "Target Server", "Done" }, ct);
await RunStep("port_status",
new[] { "Scanning", "Port Number", "Done" }, ct);
await RunStep("protocol_status",
new[] { "Shimmying", "Transmuting", "Perusing", "Protocol Type", "Done" }, ct);
await RunStep("server_status_check",
new[] { "ServerStatus", "Done" }, ct);
await RunStep("speed_status",
new[] { "Speed", "Done" }, ct);
}
#endregion
#region Helper
private async UniTask RunStep(string propertyId, string[] statuses, CancellationToken ct)
/// <summary>
/// 단계를 실행합니다. 다음 단계를 계속 진행해야 하면 true, 중지해야 하면 false를 반환합니다.
/// </summary>
private async UniTask<bool> RunStep(StepDefinition def, CancellationToken ct)
{
_currentPropertyId = propertyId;
_currentPropertyId = def.PropertyId;
foreach (var status in statuses)
// SubSteps 순차 진행
for (int i = 0; i < def.SubSteps.Length; i++)
{
SendMessage(propertyId, status);
_stepStates[propertyId] = status;
Debug.Log($"[TwinAgentAutoProcessor] {propertyId}: {status}");
var status = StepConfig.GetSubStepText(def.SubSteps[i]);
SendMessage(def.PropertyId, status);
_messageHistory.Add((def.PropertyId, status));
_stepStates[def.PropertyId] = status;
Debug.Log($"[TwinAgentAutoProcessor] {def.PropertyId}: {status}");
if (status != "Done")
{
int delayMs = (int)(UnityEngine.Random.Range(1f, 2f) * 1000);
await UniTask.Delay(delayMs, cancellationToken: ct);
}
var progressInfo = new StepProgressInfo(
def.Step, def.PropertyId, status, i, def.TotalCount, false);
onSubStepChanged?.Invoke(progressInfo);
if (i == def.SubSteps.Length - 1)
onLastSubStepEntered?.Invoke(progressInfo);
int delayMs = (int)(UnityEngine.Random.Range(0.5f, 1.5f) * 1000);
await UniTask.Delay(delayMs, cancellationToken: ct);
}
// 완료 상태 선택 (가중치 기반) 및 전송
var completion = def.SelectCompletion();
var completionStatus = completion.Status;
SendMessage(def.PropertyId, completionStatus);
_messageHistory.Add((def.PropertyId, completionStatus));
_stepStates[def.PropertyId] = completionStatus;
Debug.Log($"[TwinAgentAutoProcessor] {def.PropertyId}: {completionStatus} (완료)");
onSubStepChanged?.Invoke(new StepProgressInfo(
def.Step, def.PropertyId, completionStatus,
def.SubSteps.Length, def.TotalCount, true, completion.Result));
onStepCompleted?.Invoke(def.Step, completionStatus, completion.Result);
return !completion.StopsProcess;
}
#endregion

View File

@@ -79,7 +79,7 @@ namespace UVC.UI.Window.PropertyWindow
// 1. object_name 속성 추가 (수정 가능, 그룹 없이 개별)
var nameProperty = new StringProperty("object_name", "Name",
stageObject.GameObject != null ? stageObject.GameObject.name : "Unknown")
stageObject.gameObject != null ? stageObject.gameObject.name : "Unknown")
{
IsReadOnly = false,
Order = orderIndex++
@@ -87,9 +87,9 @@ namespace UVC.UI.Window.PropertyWindow
entries.Add(nameProperty);
// 2. Transform 그룹 추가
if (stageObject.GameObject != null)
if (stageObject.gameObject != null)
{
var transform = stageObject.GameObject.transform;
var transform = stageObject.gameObject.transform;
var transformGroup = new PropertyGroup("transform", "Transform", order: orderIndex++);
transformGroup.AddItems(new IPropertyItem[]
{
@@ -240,9 +240,11 @@ private static EntityTabData? CreateNetworkTab(StageObjectManager.StageObject st
Debug.Log("[EntityPropertyAdapter] TwinAgentAutoProcessor 생성 및 등록");
}
// 이벤트 바인딩 (기존 구독 정리 후 최신 propertyDict로 재구독)
processor.ClearBindings();
processor.onMessage += (propertyId, value) =>
// 이벤트 바인딩 (이전 스코프 구독 정리 후 최신 propertyDict로 재구독)
processor.ClearScopedBindings();
// UI 핸들러를 변수에 저장 (스코프 기반 정리용)
Action<string, string> messageHandler = (propertyId, value) =>
{
if (!propertyDict.TryGetValue(propertyId, out var item))
return;
@@ -262,7 +264,7 @@ private static EntityTabData? CreateNetworkTab(StageObjectManager.StageObject st
item.SetValue(value);
};
processor.onReset += () =>
Action resetHandler = () =>
{
foreach (var kvp in propertyDict)
{
@@ -273,70 +275,15 @@ private static EntityTabData? CreateNetworkTab(StageObjectManager.StageObject st
}
};
// 백그라운드 완료/취소 시 버튼 텍스트 갱신
processor.onComplete += () => { autoButton.ButtonText = "Run"; };
processor.onCancel += () => { autoButton.ButtonText = "Run"; };
Action completeHandler = () => { autoButton.ButtonText = "Run"; };
Action cancelHandler = () => { autoButton.ButtonText = "Run"; };
// TerminalView 바인딩 (터미널 스타일 로그)
if (terminalView != null)
{
// propertyId → 표시명 매핑
var stepNames = new Dictionary<string, string>
{
["get_entity_info_status"] = "Get Entity Info",
["extract_network_info_status"] = "Extract Network Info",
["connecting_status"] = "Connecting",
["server_status"] = "Server",
["port_status"] = "Port",
["protocol_status"] = "Protocol",
["server_status_check"] = "Status Check",
["speed_status"] = "Speed"
};
processor.onMessage += messageHandler;
processor.onReset += resetHandler;
processor.onComplete += completeHandler;
processor.onCancel += cancelHandler;
var tv = terminalView; // 클로저 캡처용 로컬 변수
processor.onStart += () =>
{
tv.AddLog("> Starting Twin Agent Auto Process...", Color.cyan);
};
processor.onMessage += (propId, value) =>
{
// ProcessorId 자체의 상태 메시지는 무시 (onStart/onComplete에서 처리)
if (propId == processor.ProcessorId) return;
string stepName = stepNames.TryGetValue(propId, out var name) ? name : propId;
if (value == "Done")
{
tv.AddLog($"> [{stepName}] Done", Color.green);
}
else if (value == "Canceled")
{
tv.AddLog($"> [{stepName}] Canceled", new Color(1f, 0.647f, 0f));
}
else
{
tv.AddLog($"> [{stepName}] {value}");
}
};
processor.onComplete += () =>
{
tv.AddLog("> Process completed successfully", Color.green);
};
processor.onCancel += () =>
{
tv.AddLog("> Process canceled", new Color(1f, 0.647f, 0f));
};
processor.onReset += () =>
{
tv.Clear();
};
}
// 저장된 상태 복원
// PropertyItem UI 상태 복원 (터미널 핸들러 구독 전에 수행하여 터미널에 중복 기록 방지)
if (processor.HasSavedState)
{
processor.RestoreState();
@@ -347,6 +294,112 @@ private static EntityTabData? CreateNetworkTab(StageObjectManager.StageObject st
autoButton.ButtonText = "Run";
}
// TerminalView 바인딩 (터미널 스타일 로그)
// propertyId → 표시명 매핑
var stepNames = new Dictionary<string, string>
{
["get_entity_info_status"] = "Get Entity Info",
["extract_network_info_status"] = "Extract Network Info",
["connecting_status"] = "Connecting",
["server_status"] = "Server",
["port_status"] = "Port",
["protocol_status"] = "Protocol",
["server_status_check"] = "Status Check",
["speed_status"] = "Speed"
};
Action? startTerminal = null;
Action<string, string>? messageTerminal = null;
Action? completeTerminal = null;
Action? cancelTerminal = null;
Action? resetTerminal = null;
if (terminalView != null)
{
var tv = terminalView; // 클로저 캡처용 로컬 변수
startTerminal = () =>
{
tv.AddLog("> Starting Twin Agent Auto Process...", Color.cyan);
};
messageTerminal = (propId, value) =>
{
// ProcessorId 자체의 상태 메시지는 무시 (onStart/onComplete에서 처리)
if (propId == processor.ProcessorId) return;
string stepName = stepNames.TryGetValue(propId, out var name) ? name : propId;
if (value == "Done")
tv.AddLog($"> [{stepName}] Done", Color.green);
else if (value == "Canceled")
tv.AddLog($"> [{stepName}] Canceled", new Color(1f, 0.647f, 0f));
else
tv.AddLog($"> [{stepName}] {value}");
};
completeTerminal = () =>
{
tv.AddLog("> Process completed successfully", Color.green);
};
cancelTerminal = () =>
{
tv.AddLog("> Process canceled", new Color(1f, 0.647f, 0f));
};
resetTerminal = () =>
{
tv.Clear();
};
processor.onStart += startTerminal;
processor.onMessage += messageTerminal;
processor.onComplete += completeTerminal;
processor.onCancel += cancelTerminal;
processor.onReset += resetTerminal;
// 터미널 로그 복원: MessageHistory에서 Entity별 이력 재생
tv.Clear();
if (processor.HasSavedState)
{
tv.AddLog("> Starting Twin Agent Auto Process...", Color.cyan);
foreach (var (propId, value) in processor.MessageHistory)
{
if (propId == processor.ProcessorId) continue;
string stepName = stepNames.TryGetValue(propId, out var sn) ? sn : propId;
if (value == "Done")
tv.AddLog($"> [{stepName}] Done", Color.green);
else if (value == "Canceled")
tv.AddLog($"> [{stepName}] Canceled", new Color(1f, 0.647f, 0f));
else
tv.AddLog($"> [{stepName}] {value}");
}
// 완료/취소 상태에 따라 터미널 종료 메시지 추가
if (processor.IsCompleted)
tv.AddLog("> Process completed successfully", Color.green);
else if (!processor.IsRunning)
tv.AddLog("> Process canceled", new Color(1f, 0.647f, 0f));
}
}
// 스코프 정리 액션 등록 (다음 ClearScopedBindings 호출 시 이 핸들러들만 해제)
processor.SetScopedCleanup(() =>
{
processor.onMessage -= messageHandler;
processor.onReset -= resetHandler;
processor.onComplete -= completeHandler;
processor.onCancel -= cancelHandler;
if (startTerminal != null) processor.onStart -= startTerminal;
if (messageTerminal != null) processor.onMessage -= messageTerminal;
if (completeTerminal != null) processor.onComplete -= completeTerminal;
if (cancelTerminal != null) processor.onCancel -= cancelTerminal;
if (resetTerminal != null) processor.onReset -= resetTerminal;
});
// Auto 버튼 클릭 시 Run/Cancel 토글
autoButton.Clicked += async () =>
{
@@ -441,11 +494,32 @@ private static EntityTabData? CreateNetworkTab(StageObjectManager.StageObject st
if (processor.HasSavedState)
{
processor.RestoreState();
// 터미널 로그: MessageHistory에서 Entity별 이력 재생
terminalView?.Clear();
terminalView?.AddLog("> Starting Twin Agent Auto Process...", Color.cyan);
foreach (var (propId, value) in processor.MessageHistory)
{
if (propId == processor.ProcessorId) continue;
string sn = stepNames.TryGetValue(propId, out var n) ? n : propId;
if (value == "Done")
terminalView?.AddLog($"> [{sn}] Done", Color.green);
else if (value == "Canceled")
terminalView?.AddLog($"> [{sn}] Canceled", new Color(1f, 0.647f, 0f));
else
terminalView?.AddLog($"> [{sn}] {value}");
}
if (processor.IsCompleted)
terminalView?.AddLog("> Process completed successfully", Color.green);
else if (!processor.IsRunning)
terminalView?.AddLog("> Process canceled", new Color(1f, 0.647f, 0f));
autoButton.ButtonText = processor.IsRunning ? "Cancel" : "Run";
Debug.Log("[EntityPropertyAdapter] Twin Agent로 전환 → 저장된 UI 상태 복원");
}
else
{
terminalView?.Clear();
Debug.Log("[EntityPropertyAdapter] Twin Agent로 전환 → 초기 상태");
}
}

View File

@@ -713,11 +713,12 @@ public void Open(UVC.Entity.Entity entity)
// Clear()는 _currentEntity를 null로 만들믰로 미리 저장해둔 previousEntity 사용
Clear();
// 2. 이전 엔티티의 프로세서는 취소하지 않음 (백그라운드에서 계속 실행)
// 다시 해당 엔티티를 선택하면 진행 상황이 복원됨
// 2. 이전 엔티티의 UI 바인딩 해제 (프로세서는 백그라운드에서 계속 실행)
// 공유 TerminalView에 이전 Entity의 로그가 침범하지 않도록 scoped bindings 해제
if (previousEntity != null)
{
Debug.Log($"[PropertyWindow] 이전 엔티티 ({previousEntity.Name})의 프로세서는 백그라운드에서 계속 실행");
previousEntity.ClearAllProcessorScopedBindings();
Debug.Log($"[PropertyWindow] 이전 엔티티 ({previousEntity.Name})의 UI 바인딩 해제 (프로세서는 계속 실행)");
}
// 3. 새 엔티티 설정

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -65,6 +65,11 @@ namespace OCTOPUS_TWIN
public bool IsRouteActive => routeSequence != null && routeSequence.IsActive();
private Sequence routeSequence;
private CameraRoute currentRoute;
public bool IsRouteRunning { get; private set; }
public Action onRouteComplete;
public CameraRoute route;
public bool IsClickUI
{
get
@@ -150,6 +155,10 @@ namespace OCTOPUS_TWIN
private void LateUpdate()
{
if (Input.GetKeyDown(KeyCode.Q))
{
SetRoute(route);
}
//UI <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ī<>޶<EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>
if (IsClickUI || IsOnTheUI)
return;
@@ -232,11 +241,11 @@ namespace OCTOPUS_TWIN
currentAzimuth += input.mouseX * rotateSpeed;
currentAzimuth %= 360;
//if(viewMode == ViewMode.PerspectiveView)
//{
// currentElevation -= input.mouseY * rotateSpeed;
// currentElevation = Mathf.Clamp(currentElevation, minElevation, maxElevation);
//}
if(viewMode == ViewMode.PerspectiveView)
{
currentElevation -= input.mouseY * rotateSpeed;
currentElevation = Mathf.Clamp(currentElevation, minElevation, maxElevation);
}
isRotateOperation = true;
}
@@ -354,71 +363,122 @@ namespace OCTOPUS_TWIN
throw new NotImplementedException();
}
public void SetRoute(CameraRoute route)
{
StopRoute();
currentRoute = route;
var points = currentRoute.movePoints;
if (points == null || points.Length == 0) return;
IsRouteRunning = true;
Enable = false;
routeSequence = DOTween.Sequence();
camera.transform.position = points[0].transform.position;
camera.transform.rotation = points[0].transform.rotation;
for (int i = 0; i < route.PointCount; i++)
if (points.Length == 1)
{
var (position, point) = route.GetWaypoint(i);
float elev = point != null ? point.elevation : currentElevation;
float azi = point != null ? point.azimuth : currentAzimuth;
float dist = point != null ? point.distance : currentDistance;
float dur = point != null ? point.duration : 0.5f;
float wait = point != null ? point.waitTime : 0f;
routeSequence.Append(
DOTween.To(() => nextPosition, x => nextPosition = x, position, dur));
routeSequence.Join(
DOTween.To(() => currentElevation, x => currentElevation = x, elev, dur));
routeSequence.Join(
DOTween.To(() => currentAzimuth, x => currentAzimuth = x, azi, dur));
routeSequence.Join(
DOTween.To(() => currentDistance, x => currentDistance = x, dist, dur));
if (wait > 0f)
routeSequence.AppendInterval(wait);
CompleteRoute();
return;
}
if (route.loop)
routeSequence.SetLoops(-1, LoopType.Restart);
int segCount = points.Length - 1;
float[] segDurations = new float[segCount];
float totalDuration = 0f;
for (int i = 0; i < segCount; i++)
{
float dist = Vector3.Distance(points[i].transform.position, points[i + 1].transform.position);
float spd = points[i + 1].speed > 0f ? points[i + 1].speed : moveSpeed;
segDurations[i] = spd > 0f ? dist / spd : 1f;
totalDuration += segDurations[i];
}
routeSequence.OnUpdate(LastPositioning);
routeSequence.OnKill(() => Enable = true);
float[] segEnd = new float[segCount];
float acc = 0f;
for (int i = 0; i < segCount; i++)
{
acc += segDurations[i];
segEnd[i] = acc / totalDuration;
}
Vector3[] pos = new Vector3[points.Length];
Quaternion[] rot = new Quaternion[points.Length];
for (int i = 0; i < points.Length; i++)
{
pos[i] = points[i].transform.position;
rot[i] = points[i].transform.rotation;
}
routeSequence = DOTween.Sequence();
routeSequence.Append(
DOVirtual.Float(0f, 1f, totalDuration, t =>
{
int seg = segCount - 1;
for (int i = 0; i < segEnd.Length; i++)
{
if (t <= segEnd[i]) { seg = i; break; }
}
float start = seg > 0 ? segEnd[seg - 1] : 0f;
float localT = Mathf.InverseLerp(start, segEnd[seg], t);
int idx = seg + 1;
Vector3 p0 = (idx >= 2) ? pos[idx - 2] : pos[idx - 1];
Vector3 p1 = pos[idx - 1];
Vector3 p2 = pos[idx];
Vector3 p3 = (idx + 1 < pos.Length) ? pos[idx + 1] : pos[idx];
camera.transform.position = CatmullRom(p0, p1, p2, p3, localT);
camera.transform.rotation = Quaternion.Slerp(rot[idx - 1], rot[idx], localT);
}).SetEase(Ease.Linear));
routeSequence.OnComplete(CompleteRoute);
}
private static Vector3 CatmullRom(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t)
{
float t2 = t * t;
float t3 = t2 * t;
return 0.5f * (
2f * p1 +
(-p0 + p2) * t +
(2f * p0 - 5f * p1 + 4f * p2 - p3) * t2 +
(-p0 + 3f * p1 - 3f * p2 + p3) * t3
);
}
public void StopRoute()
{
if (routeSequence != null && routeSequence.IsActive())
{
routeSequence.Kill();
routeSequence = null;
}
if (!IsRouteRunning) return;
routeSequence?.Kill();
routeSequence = null;
currentRoute = null;
IsRouteRunning = false;
SyncFromCamera();
Enable = true;
}
public void AnimateToState(Vector3 pivotPosition, Vector3 eulerAngles, float distance, float duration = 0.4f)
private void CompleteRoute()
{
// <20>ִϸ<D6B4><CFB8>̼<EFBFBD> <20>߿<EFBFBD><DFBF><EFBFBD> <20><><EFBFBD><20>Է<EFBFBD> <20><>Ȱ<EFBFBD><C8B0>ȭ
Enable = false;
// DoTween<65><6E> <20><><EFBFBD><EFBFBD>Ͽ<EFBFBD> <20><>Ʈ<EFBFBD>ѷ<EFBFBD><D1B7><EFBFBD> <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20>ε巴<CEB5><E5B7B4> <20><><EFBFBD><EFBFBD>
DOTween.To(() => nextPosition, x => nextPosition = x, pivotPosition, duration);
DOTween.To(() => currentElevation, x => currentElevation = x, eulerAngles.x, duration);
DOTween.To(() => currentAzimuth, x => currentAzimuth = x, eulerAngles.y, duration);
DOTween.To(() => currentDistance, x => currentDistance = x, distance, duration)
.OnComplete(() => {
// <20>ִϸ<D6B4><CFB8>̼<EFBFBD><CCBC><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><20>Է<EFBFBD><D4B7><EFBFBD> <20>ٽ<EFBFBD> Ȱ<><C8B0>ȭ
Enable = true;
});
routeSequence = null;
currentRoute = null;
IsRouteRunning = false;
SyncFromCamera();
Enable = true;
onRouteComplete?.Invoke();
}
//public bool IsOperation()
//{
// if(is)
//}
private void SyncFromCamera()
{
var euler = camera.transform.eulerAngles;
currentElevation = euler.x;
currentAzimuth = euler.y;
var offset = Quaternion.Euler(euler.x, euler.y, 0f) * new Vector3(0, 0, -currentDistance);
nextPosition = camera.transform.position - offset;
cameraPivot.transform.position = nextPosition;
}
}
}

View File

@@ -31,7 +31,7 @@ Material:
m_LightmapFlags: 6
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: 2100
m_CustomRenderQueue: 3000
stringTagMap:
RenderType: Transparent
disabledShaderPasses: