<feat> 모터 상태 api통신
This commit is contained in:
@@ -1,20 +0,0 @@
|
||||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
|
||||
[System.Serializable]
|
||||
public class IKData
|
||||
{
|
||||
public string robotId;
|
||||
public Vector3 position;
|
||||
public List<Vector3> nodesPosition;
|
||||
public List<Quaternion> nodesRotation;
|
||||
public float deltaTime;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
public class IKDataListWrapper
|
||||
{
|
||||
public string robotId;
|
||||
public List<IKData> keyFrameList;
|
||||
//public List<Vector3> nodesList;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1073b77c68490294db77f64de40d82cd
|
||||
@@ -1,136 +0,0 @@
|
||||
using UnityEngine;
|
||||
using uPLibrary.Networking.M2Mqtt;
|
||||
using uPLibrary.Networking.M2Mqtt.Messages;
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
public class MqttManager : MonoBehaviour
|
||||
{
|
||||
public static MqttManager Instance { get; private set; }
|
||||
|
||||
[Header("MQTT Broker Settings")]
|
||||
[SerializeField] private string brokerAddress = "localhost";
|
||||
[SerializeField] private int brokerPort = 1883;
|
||||
|
||||
[Header("Robot Config")]
|
||||
[SerializeField] private TextAsset robotIDconfig;
|
||||
[SerializeField] private TextAsset robotActionTopics;
|
||||
|
||||
private MqttClient client;
|
||||
|
||||
public static event Action<string, string> OnMessageReceived;
|
||||
private readonly ConcurrentQueue<Tuple<string, string>> receivedMessages = new ConcurrentQueue<Tuple<string, string>>();
|
||||
|
||||
List<string> topicList=new List<string>();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 싱글톤 인스턴스 설정
|
||||
if (Instance == null)
|
||||
{
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
else
|
||||
{
|
||||
Destroy(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
ConnectToBroker();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// 매 프레임마다 큐에 쌓인 메시지가 있는지 확인
|
||||
while (receivedMessages.TryDequeue(out var messageItem))
|
||||
{
|
||||
// 메인 스레드에서 안전하게 이벤트를 발생시킴
|
||||
OnMessageReceived?.Invoke(messageItem.Item1, messageItem.Item2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void ConnectToBroker()
|
||||
{
|
||||
if (client != null && client.IsConnected)
|
||||
{
|
||||
Debug.LogWarning("Already connected to MQTT Broker");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
client = new MqttClient(brokerAddress, brokerPort, false, null, null, MqttSslProtocols.None);
|
||||
client.MqttMsgPublishReceived += OnMqttMessageReceived;
|
||||
|
||||
string clientId = Guid.NewGuid().ToString();
|
||||
client.Connect(clientId);
|
||||
|
||||
if (client.IsConnected)
|
||||
{
|
||||
Debug.Log("Connected to MQTT Broker");
|
||||
SetSubscribeTopics();
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"Failed to connect to MQTT Broker: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 별도의 스레드에서 실행됨 (MqttManager는 백그라운드에서 네트워크 메시지 받음) -> 메시지 큐 방식 사용
|
||||
private void OnMqttMessageReceived(object sender, MqttMsgPublishEventArgs e)
|
||||
{
|
||||
string message = Encoding.UTF8.GetString(e.Message);
|
||||
receivedMessages.Enqueue(new Tuple<string, string>(e.Topic, message));
|
||||
}
|
||||
|
||||
public void PublishMessage(string topic, string message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(topic))
|
||||
{
|
||||
Debug.LogError("MQTT Publish Canceled: Topic is null or empty!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (client == null || !client.IsConnected) return;
|
||||
byte[] messageBytes = Encoding.UTF8.GetBytes(message);
|
||||
client.Publish(topic, messageBytes, MqttMsgBase.QOS_LEVEL_AT_MOST_ONCE, false);
|
||||
}
|
||||
|
||||
void SetSubscribeTopics()
|
||||
{
|
||||
var topics = JsonUtility.FromJson<RobotIDConfig>(robotIDconfig.text);
|
||||
var actions = JsonUtility.FromJson<RobotActionTopics>(robotActionTopics.text);
|
||||
|
||||
topicList.Add($"robots/{topics.ID}/{actions.Topic1}");
|
||||
topicList.Add($"robots/{topics.ID}/{actions.Topic2}");
|
||||
SubscribeToTopics(topicList.ToArray());
|
||||
}
|
||||
|
||||
public void SubscribeToTopics(string[] topics)
|
||||
{
|
||||
if (client == null || !client.IsConnected || topics == null) return;
|
||||
byte[] qosLevels = new byte[topics.Length];
|
||||
for (int i = 0; i < topics.Length; i++)
|
||||
{
|
||||
qosLevels[i] = MqttMsgBase.QOS_LEVEL_AT_LEAST_ONCE;
|
||||
}
|
||||
|
||||
client.Subscribe(topics, qosLevels);
|
||||
Debug.Log($"토픽 구독 시작: {string.Join(", ",topics)}");
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (client != null && client.IsConnected)
|
||||
{
|
||||
client.Disconnect();
|
||||
Debug.Log("Disconnected from MQTT Broker");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5491d87c5326c7640be51aa08dba16a7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: -200
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,8 +0,0 @@
|
||||
using System;
|
||||
|
||||
[Serializable]
|
||||
public class RobotActionTopics
|
||||
{
|
||||
public string Topic1;
|
||||
public string Topic2;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8e59e2e5a53eb5846a7c7ac4b9995c16
|
||||
@@ -4,9 +4,6 @@ using System;
|
||||
|
||||
public class RobotController : MonoBehaviour
|
||||
{
|
||||
public string robotId = "robot1"; // 로봇 ID
|
||||
public bool isLocallyControlled = false; // 로컬에서 제어 여부
|
||||
|
||||
[Header("Motor State")]
|
||||
[SerializeField] private GameObject motorStatusIndicator1;
|
||||
[SerializeField] private GameObject motorStatusIndicator2;
|
||||
@@ -14,7 +11,7 @@ public class RobotController : MonoBehaviour
|
||||
[SerializeField] private Material indicatorMaterial1; // ±âº»»ö(ȸ»ö)
|
||||
[SerializeField] private Material indicatorMaterial2; // ÃÊ·Ï
|
||||
|
||||
private bool isMotorOn = false;
|
||||
private bool isMotorOn;
|
||||
|
||||
void Start()
|
||||
{
|
||||
@@ -26,14 +23,8 @@ public class RobotController : MonoBehaviour
|
||||
{
|
||||
motorStatusIndicator2.GetComponent<MeshRenderer>().material = indicatorMaterial1;
|
||||
}
|
||||
|
||||
SetMotorState(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 로봇의 모터 상태 설정 (나중에 UI에서 호출할 함수)
|
||||
/// </summary>
|
||||
/// <param name="isOn">모터를 켤지 여부</param>
|
||||
public void SetMotorState(bool isOn)
|
||||
{
|
||||
isMotorOn = isOn;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
using System;
|
||||
|
||||
[Serializable]
|
||||
public class RobotIDConfig
|
||||
{
|
||||
public string ID;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7d96c46e3e183d44bbc30211a6e9821d
|
||||
@@ -1,239 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
public class RobotManager : MonoBehaviour
|
||||
{
|
||||
public static RobotManager Instance { get; private set; }
|
||||
|
||||
UIManager uiManager => UIManager.Instance;
|
||||
|
||||
[Header("MQTT Settings")]
|
||||
[SerializeField] private MqttManager mqttManager;
|
||||
|
||||
[Header("Robot Config")]
|
||||
[SerializeField] private TextAsset robotIDconfig;
|
||||
[SerializeField] private TextAsset robotActionTopics;
|
||||
[SerializeField] private HybridInverseKinematicsNode hybridIKNode;
|
||||
|
||||
[Header("UI Settings")]
|
||||
[Tooltip("각 로봇에 대해 생성할 UI 프리팹")]
|
||||
[SerializeField] private GameObject robotUIPrefab;
|
||||
[Tooltip("생성된 UI들이 위치할 부모 Canvas")]
|
||||
[SerializeField] private Transform uiParentCanvas;
|
||||
|
||||
// --- MQTT Topic 관리 ---
|
||||
private RobotIDConfig idConfigData;
|
||||
private RobotActionTopics actionTopicsData;
|
||||
|
||||
// --- 로봇 관리 ---
|
||||
private readonly Dictionary<string, RobotController> robotRegistry = new Dictionary<string, RobotController>();// 로봇 ID를 키로 하여 관리
|
||||
private string targetRobotId; // 현재 조작 중인 로봇 ID (가상 = 현실)
|
||||
private Dictionary<string, GameObject> robotUIs = new Dictionary<string, GameObject>();
|
||||
|
||||
// --- 로봇 선택 및 기록 관리 ---
|
||||
private RobotController selectedRobot; // 현재 선택된 로봇
|
||||
private Transform RecordingHandleTarget; // Handle(잡는 부분)의 Transform
|
||||
public static event Action<RobotController> OnRobotSelected; // 로봇 선택 이벤트
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance == null)
|
||||
{
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
else
|
||||
{
|
||||
Destroy(gameObject);
|
||||
}
|
||||
|
||||
InitializeRobotRegistry();
|
||||
ManageConfig();
|
||||
}
|
||||
|
||||
// 이벤트 구독 관리
|
||||
private void OnEnable()
|
||||
{
|
||||
MqttManager.OnMessageReceived += HandleMessage; // 메시지 수신 이벤트 구독
|
||||
UIController.OnTransformSendUIClicked += HandlePublishTransformCommand;
|
||||
UIController.OnKeyframesSendUIClicked += HandlePublishKeyframeListCommand;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
MqttManager.OnMessageReceived -= HandleMessage;
|
||||
UIController.OnTransformSendUIClicked -= HandlePublishTransformCommand;
|
||||
UIController.OnKeyframesSendUIClicked -= HandlePublishKeyframeListCommand;
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
InstantiateRobotUIs();
|
||||
}
|
||||
|
||||
private void InitializeRobotRegistry()
|
||||
{
|
||||
RobotController[] allRobots = FindObjectsByType<RobotController>(FindObjectsSortMode.None); // 비활성화 된 건 빼고 찾기
|
||||
foreach (var robot in allRobots)
|
||||
{
|
||||
if (!robotRegistry.ContainsKey(robot.robotId))
|
||||
{
|
||||
robotRegistry.Add(robot.robotId, robot);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"로봇 ID {robot.robotId}가 중복되었습니다. 첫 번째 로봇만 등록됩니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void InstantiateRobotUIs()
|
||||
{
|
||||
if (robotUIPrefab == null || uiParentCanvas == null)
|
||||
{
|
||||
Debug.LogError("Robot UI Prefab 또는 UI Parent Canvas가 설정되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
foreach (var entry in robotRegistry)
|
||||
{
|
||||
RobotController robot = entry.Value;
|
||||
// '가상 로봇 (트윈)'에 대해서만 UI를 생성
|
||||
if (!robot.isLocallyControlled)
|
||||
{
|
||||
GameObject uiInstance = Instantiate(robotUIPrefab, uiParentCanvas);
|
||||
uiInstance.name = $"UI_{robot.robotId}";
|
||||
|
||||
// 기존 UI Prefab은 비활성화
|
||||
robotUIPrefab.SetActive(false);
|
||||
|
||||
UIController uiController = uiInstance.GetComponent<UIController>();
|
||||
if (uiController != null)
|
||||
{
|
||||
uiController.Initialize(this, uiManager);
|
||||
robotUIs.Add(robot.robotId, uiInstance);
|
||||
}
|
||||
uiInstance.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SelectRobot(string robotId)
|
||||
{
|
||||
if (robotRegistry.TryGetValue(robotId, out RobotController robot))
|
||||
{
|
||||
selectedRobot = robot;
|
||||
targetRobotId = robotId; // 현재 조작 중인 로봇 ID 설정
|
||||
Debug.Log($"로봇 '{robotId}' 선택됨. 현재 조작 중인 로봇 ID로 설정.");
|
||||
|
||||
// 기록할 Handle Target 설정
|
||||
RecordingHandleTarget = GameObject.FindWithTag("Handle")?.transform;
|
||||
|
||||
// 모든 UI 비활성화
|
||||
foreach (var ui in robotUIs.Values) ui.SetActive(false);
|
||||
|
||||
// 선택된 로봇의 UI만 활성화
|
||||
if (robotUIs.TryGetValue(robotId, out GameObject selectedUI))
|
||||
{
|
||||
selectedUI.SetActive(true);
|
||||
}
|
||||
|
||||
OnRobotSelected?.Invoke(selectedRobot); // UIManager의 HandleRobotSelected 이벤트 호출
|
||||
SubscribeToTopic(new string[] { $"robots/{targetRobotId}/ {actionTopicsData.Topic1}" }); // Topic1: state
|
||||
|
||||
HandlePublishTransformCommand(); // 선택 시 한 번 동기화
|
||||
}
|
||||
}
|
||||
|
||||
#region Publishing Logic
|
||||
|
||||
private void ManageConfig()
|
||||
{
|
||||
idConfigData = JsonUtility.FromJson<RobotIDConfig>(robotIDconfig.text);
|
||||
actionTopicsData = JsonUtility.FromJson<RobotActionTopics>(robotActionTopics.text);
|
||||
}
|
||||
|
||||
// 가상 로봇을 조작하여 현실 로봇에게 명령 발행 (UI 사용해서)
|
||||
public void HandlePublishTransformCommand()
|
||||
{
|
||||
Debug.Log("TransformSendUI 이벤트 실행되어 HandlePublishTransformCommand 불러옴");
|
||||
Vector3 commandPosition = RecordingHandleTarget.transform.localPosition;
|
||||
List<Vector3> commandJoints = hybridIKNode.GetCurrentJointPositions();
|
||||
List<Quaternion> commandRotations = hybridIKNode.GetCurrentJointRotations();
|
||||
|
||||
string topic = $"robots/{targetRobotId}/{actionTopicsData.Topic2}"; // Topic2: command
|
||||
Debug.Log($"topic: {topic}");
|
||||
|
||||
IKData data = new IKData
|
||||
{
|
||||
robotId = targetRobotId, // 로봇 ID (가상 = 현실)
|
||||
position = commandPosition,
|
||||
nodesPosition = new List<Vector3>(commandJoints),
|
||||
nodesRotation = new List<Quaternion>(commandRotations)
|
||||
};
|
||||
string json = JsonUtility.ToJson(data, true);
|
||||
mqttManager.PublishMessage(topic, json);
|
||||
Debug.Log($"위치 동기화 명령 발행: {json}");
|
||||
}
|
||||
|
||||
public void HandlePublishKeyframeListCommand()
|
||||
{
|
||||
Debug.Log("ListSendUI 이벤트 실행되어 HandlePublishKeyframeListCommand 불러옴");
|
||||
IReadOnlyList<IKData> keyframePositions = UIManager.Instance.KeyframePositions;
|
||||
|
||||
|
||||
//List<Vector3> commandJoints = hybridIKNode.GetJointPositionsForKeyframe();
|
||||
// 리스트를 메시지로 publish
|
||||
if (keyframePositions.Count > 0)
|
||||
{
|
||||
string topic = $"robots/{targetRobotId}/{actionTopicsData.Topic2}"; // Topic2: command
|
||||
Debug.Log($"topic: {topic}");
|
||||
|
||||
IKDataListWrapper wrapper = new IKDataListWrapper
|
||||
{
|
||||
robotId = targetRobotId,
|
||||
keyFrameList = new List<IKData>(keyframePositions),
|
||||
//nodesList = commandJoints
|
||||
};
|
||||
string json = JsonUtility.ToJson(wrapper, true);
|
||||
mqttManager.PublishMessage(topic, json);
|
||||
Debug.Log($"키프레임 리스트 명령 발행: {json}");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleMessage(string topic, string message)
|
||||
{
|
||||
string[] topicLevels = topic.Split('/');
|
||||
if (topicLevels.Length < 3) return;
|
||||
|
||||
string robotId = topicLevels[1];
|
||||
string messageType = topicLevels[2];
|
||||
|
||||
// ID에 해당하는 로봇이 있는지 확인
|
||||
if (!robotRegistry.TryGetValue(robotId, out RobotController targetRobot))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
//HandleStateUpdate(targetRobot, message); // TODO. 가상 트윈의 위치 업데이트 -> 실제 로봇에서 버튼 클릭하면 동기화 하기로.
|
||||
}
|
||||
|
||||
private void HandleStateUpdate(RobotController twinRobot, string message) // 가상 <- 현실
|
||||
{
|
||||
IKData data = JsonUtility.FromJson<IKData>(message);
|
||||
twinRobot.transform.localPosition = data.position;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Subscribing Logic
|
||||
|
||||
private void SubscribeToTopic(string[] topics)
|
||||
{
|
||||
// MqttManager에서 구독 관리
|
||||
mqttManager.SubscribeToTopics(topics);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ec0bbd7b79e224641af1518339a681fe
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: -100
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,27 +0,0 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
[RequireComponent(typeof(RobotController))]
|
||||
public class RobotSelector : MonoBehaviour, IPointerClickHandler
|
||||
{
|
||||
private RobotController robotController;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
robotController = GetComponent<RobotController>();
|
||||
}
|
||||
|
||||
public void OnPointerClick(PointerEventData eventData)
|
||||
{
|
||||
Debug.Log("RobotSelector OnPointerClick called.");
|
||||
if (RobotManager.Instance != null && robotController != null)
|
||||
{
|
||||
RobotManager.Instance.SelectRobot(robotController.robotId);
|
||||
Debug.Log(robotController.robotId + " Ŭ¸¯");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("RobotManager Instance or RobotController is null.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2c7de44cbc958774c8df01a2be9703bb
|
||||
@@ -1,84 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
|
||||
public class UIController : MonoBehaviour
|
||||
{
|
||||
private RobotManager robotManager;
|
||||
private UIManager uiManager;
|
||||
|
||||
public TextMeshProUGUI keyframeText;
|
||||
public List<Button> buttonsToDisable;
|
||||
|
||||
public static event Action<UIController> OnUIControllerInitialized;
|
||||
public static event Action OnTransformSendUIClicked;
|
||||
public static event Action OnKeyframesSendUIClicked;
|
||||
public static event Action OnRecordUIClicked;
|
||||
public static event Action OnDeleteUIClicked;
|
||||
public static event Action OnInitUIClicked;
|
||||
public static event Action OnRunUIClicked;
|
||||
|
||||
public void Initialize(RobotManager r_manager, UIManager u_manager)
|
||||
{
|
||||
this.robotManager = r_manager;
|
||||
this.uiManager = u_manager;
|
||||
|
||||
OnUIControllerInitialized?.Invoke(this);
|
||||
}
|
||||
|
||||
public void OnSendTransformClicked()
|
||||
{
|
||||
if (robotManager == null)
|
||||
{
|
||||
Debug.LogError("RobotManager reference is not set.");
|
||||
return;
|
||||
}
|
||||
//robotManager.HandlePublishTransformCommand();
|
||||
OnTransformSendUIClicked?.Invoke();
|
||||
Debug.Log("OnTransformSendUIClicked event invoked.");
|
||||
}
|
||||
|
||||
public void OnSendKeyframesClicked()
|
||||
{
|
||||
if (robotManager == null)
|
||||
{
|
||||
Debug.LogError("RobotManager reference is not set.");
|
||||
return;
|
||||
}
|
||||
//robotManager.HandlePublishKeyframeListCommand();
|
||||
OnKeyframesSendUIClicked?.Invoke();
|
||||
Debug.Log("OnKeyframesSendUIClicked event invoked.");
|
||||
}
|
||||
|
||||
public void OnRecordClicked()
|
||||
{
|
||||
if (uiManager == null)
|
||||
{
|
||||
Debug.LogError("UIManager reference is not set.");
|
||||
return;
|
||||
}
|
||||
OnRecordUIClicked?.Invoke();
|
||||
}
|
||||
|
||||
public void OnDeleteClicked()
|
||||
{
|
||||
if (uiManager == null) return;
|
||||
OnDeleteUIClicked?.Invoke();
|
||||
}
|
||||
public void OnClearClicked()
|
||||
{
|
||||
if (uiManager == null) return;
|
||||
OnInitUIClicked?.Invoke();
|
||||
}
|
||||
|
||||
public void OnRunClicked()
|
||||
{
|
||||
if (uiManager == null) return;
|
||||
OnRunUIClicked?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a2d0ca11794a1b45881a2e55f004d5f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 100
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,318 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
public class UIManager : MonoBehaviour
|
||||
{
|
||||
public static UIManager Instance { get; private set; }
|
||||
|
||||
#region Inspector Variables
|
||||
|
||||
[Header("Settings of Target")]
|
||||
[SerializeField] private Transform targetToRecord; // endNode 역할을 할 Transform
|
||||
[SerializeField] private GameObject IKSolver;
|
||||
[SerializeField] private TextMeshProUGUI keyframeText;
|
||||
[SerializeField] private List<Button> buttonsToDisable; // 시뮬레이션 실행 중 비활성화할 버튼들
|
||||
|
||||
[Header("System References")]
|
||||
[SerializeField] private RobotManager robotManager;
|
||||
[SerializeField] private HybridInverseKinematicsNode hybridIKNode;
|
||||
|
||||
private Transform originalTransform; // 기록 시작 전의 원래 위치 저장
|
||||
private List<IKData> keyframePositions = new List<IKData>();
|
||||
public IReadOnlyList<IKData> KeyframePositions => keyframePositions; // 외부에서 읽기 전용으로 접근 가능하게 함
|
||||
private float lastRecordingTime = 0f;
|
||||
private Coroutine simulationCoroutine = null;
|
||||
private readonly StringBuilder stringBuilder = new StringBuilder();
|
||||
|
||||
#endregion
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 싱글톤 인스턴스 설정
|
||||
if (Instance == null)
|
||||
{
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
else
|
||||
{
|
||||
Destroy(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
// 이벤트 구독 관리
|
||||
private void OnEnable()
|
||||
{
|
||||
RobotManager.OnRobotSelected += HandleRobotSelected;
|
||||
UIController.OnUIControllerInitialized += HandleUIInitialize;
|
||||
UIController.OnRecordUIClicked += RecordPosition;
|
||||
UIController.OnDeleteUIClicked += DeleteLastRecordedPosition;
|
||||
UIController.OnInitUIClicked += InitRecordedPositions;
|
||||
UIController.OnRunUIClicked += RunSimulation;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
RobotManager.OnRobotSelected -= HandleRobotSelected;
|
||||
UIController.OnUIControllerInitialized -= HandleUIInitialize;
|
||||
UIController.OnRecordUIClicked -= RecordPosition;
|
||||
UIController.OnDeleteUIClicked -= DeleteLastRecordedPosition;
|
||||
UIController.OnInitUIClicked -= InitRecordedPositions;
|
||||
UIController.OnRunUIClicked -= RunSimulation;
|
||||
}
|
||||
|
||||
// RobotManager로부터 로봇이 선택되었다는 소식을 받았을 때 실행될 함수
|
||||
private void HandleRobotSelected(RobotController selectedRobot)
|
||||
{
|
||||
if (selectedRobot != null)
|
||||
{
|
||||
Transform handleTransform = selectedRobot.transform.Find("Handle");
|
||||
IKSolver = selectedRobot.GetComponentInChildren<HybridInverseKinematicsNode>()?.gameObject;
|
||||
|
||||
if (handleTransform != null)
|
||||
{
|
||||
this.targetToRecord = handleTransform;
|
||||
Debug.Log($"UIManager: 기록 대상이 '{selectedRobot.robotId}'의 Handle로 변경.");
|
||||
originalTransform = targetToRecord;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"'{selectedRobot.robotId}'에서 Handle을 찾지 못함.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UIController가 초기화되었을 때 실행될 함수
|
||||
private void HandleUIInitialize(UIController controller)
|
||||
{
|
||||
Debug.Log("UIManager: UIController 초기화 이벤트 수신됨.");
|
||||
if (controller != null && controller.keyframeText != null && controller.buttonsToDisable != null)
|
||||
{
|
||||
this.keyframeText = controller.keyframeText;
|
||||
Debug.Log("UIManager: Keyframe Text가 UIController로부터 설정됨.");
|
||||
this.buttonsToDisable = controller.buttonsToDisable;
|
||||
Debug.Log("UIManager: Buttons to Disable가 UIController로부터 설정됨.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("UIController 또는 Keyframe Text/ButtonsToDisable이 null임.");
|
||||
}
|
||||
}
|
||||
|
||||
#region Public Methods
|
||||
|
||||
public void RecordPosition()
|
||||
{
|
||||
if (targetToRecord == null)
|
||||
{
|
||||
Debug.LogError("Target Transform이 지정되지 않았습니다!");
|
||||
return;
|
||||
}
|
||||
float currentTime = Time.time;
|
||||
float timeSinceLastFrame = (keyframePositions.Count > 0) ? currentTime - lastRecordingTime : 0f;
|
||||
|
||||
IKData newFrame = new IKData
|
||||
{
|
||||
position = targetToRecord.localPosition,
|
||||
nodesPosition = new List<Vector3>(hybridIKNode.GetCurrentJointPositions()),
|
||||
nodesRotation = new List<Quaternion>(hybridIKNode.GetCurrentJointRotations()),
|
||||
deltaTime = timeSinceLastFrame,
|
||||
};
|
||||
|
||||
keyframePositions.Add(newFrame);
|
||||
lastRecordingTime = currentTime;
|
||||
Debug.Log($"키프레임 기록됨: 위치={newFrame.position}, 시간 간격={newFrame.deltaTime}");
|
||||
|
||||
UpdateKeyframeUI();
|
||||
}
|
||||
|
||||
public void DeleteLastRecordedPosition()
|
||||
{
|
||||
if (keyframePositions.Count > 0)
|
||||
{
|
||||
keyframePositions.RemoveAt(keyframePositions.Count - 1);
|
||||
UpdateKeyframeUI();
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log("No recorded positions to delete.");
|
||||
}
|
||||
}
|
||||
|
||||
public void InitRecordedPositions()
|
||||
{
|
||||
StartCoroutine(ResetPositionRoutine());
|
||||
}
|
||||
|
||||
private IEnumerator ResetPositionRoutine()
|
||||
{
|
||||
TargetRangeLimiter limiter = targetToRecord.GetComponent<TargetRangeLimiter>();
|
||||
|
||||
if (limiter != null) limiter.enabled = false;
|
||||
|
||||
keyframePositions.Clear();
|
||||
UpdateKeyframeUI();
|
||||
targetToRecord.localPosition = originalTransform.localPosition;
|
||||
Debug.Log("Recorded positions initialized.");
|
||||
|
||||
// 한 프레임만 기다림
|
||||
yield return null;
|
||||
|
||||
// 다시 활성화
|
||||
if (limiter != null) limiter.enabled = true;
|
||||
}
|
||||
|
||||
public void RunSimulation()
|
||||
{
|
||||
if (keyframePositions.Count == 0)
|
||||
{
|
||||
Debug.Log("No recorded positions to simulate.");
|
||||
return;
|
||||
}
|
||||
if (simulationCoroutine != null)
|
||||
{
|
||||
StopCoroutine(simulationCoroutine);
|
||||
}
|
||||
SetButtonsInteractable(false);
|
||||
IKSolver.SetActive(false);
|
||||
simulationCoroutine = StartCoroutine(SimulateMovement());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Inner Logic & Coroutines
|
||||
|
||||
private void SetButtonsInteractable(bool isInteractable)
|
||||
{
|
||||
foreach (Button button in buttonsToDisable)
|
||||
{
|
||||
if (button != null)
|
||||
{
|
||||
button.interactable = isInteractable;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateKeyframeUI()
|
||||
{
|
||||
if (keyframeText == null)
|
||||
{
|
||||
Debug.LogError("Keyframe Text가 지정되지 않았습니다!");
|
||||
return;
|
||||
}
|
||||
// StringBuilder를 사용하여 불필요한 메모리 할당을 방지
|
||||
stringBuilder.Clear();
|
||||
|
||||
for (int i = 0; i < keyframePositions.Count; i++)
|
||||
{
|
||||
// Vector3의 소수점을 2자리까지만 표시
|
||||
string positionText = keyframePositions[i].position.ToString("F2");
|
||||
string deltaTimeText = keyframePositions[i].deltaTime.ToString("F2");
|
||||
stringBuilder.AppendLine($"[{i + 1}] pos:{positionText}, time:{deltaTimeText}");
|
||||
}
|
||||
|
||||
keyframeText.text = stringBuilder.ToString(); // 최종적으로 완성된 문자열을 한 번만 할당
|
||||
}
|
||||
|
||||
private IEnumerator SimulateMovement()
|
||||
{
|
||||
//Debug.Log("시뮬레이션 시작.");
|
||||
//Vector3 startPosition = keyframePositions[0].position;
|
||||
|
||||
//foreach (IKData target in keyframePositions)
|
||||
//{
|
||||
// Vector3 targetPosition = target.position;
|
||||
// float moveDuration = target.deltaTime; // 수신된 시간 간격
|
||||
|
||||
// if (moveDuration <= 0f)
|
||||
// {
|
||||
// targetToRecord.localPosition = targetPosition;
|
||||
// startPosition = targetPosition;
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// float elapsedTime = 0f;
|
||||
|
||||
// while (elapsedTime < moveDuration)
|
||||
// {
|
||||
// elapsedTime += Time.deltaTime;
|
||||
// float t = elapsedTime / moveDuration; // 이동 진행도 계산 (0.0 ~ 1.0)
|
||||
// targetToRecord.localPosition = Vector3.Lerp(startPosition, targetPosition, t); // Lerp 함수로 시작점과 목표점 사이의 현재 위치 계산
|
||||
// yield return null;
|
||||
// }
|
||||
|
||||
// targetToRecord.localPosition = targetPosition; // 오차 없애기 위해 이동이 끝나면 목표 위치로 정확히 설정
|
||||
// startPosition = targetPosition;
|
||||
//}
|
||||
Debug.Log("동기화 시뮬레이션 시작");
|
||||
if (hybridIKNode == null || keyframePositions == null)
|
||||
{
|
||||
Debug.LogWarning("타겟 IK나 키프레임 리스트가 비어있음");
|
||||
yield break;
|
||||
}
|
||||
|
||||
Transform[] jointTransforms = new Transform[hybridIKNode.nodes.Count];
|
||||
Debug.Log($"관절 개수: {hybridIKNode.nodes.Count}");
|
||||
for (int i = 0; i < hybridIKNode.nodes.Count; i++)
|
||||
{
|
||||
jointTransforms[i] = hybridIKNode.nodes[i].jointTransform;
|
||||
}
|
||||
|
||||
foreach (IKData targetKeyframe in keyframePositions)
|
||||
{
|
||||
float moveDuration = targetKeyframe.deltaTime; // 수신된 시간 간격
|
||||
|
||||
Debug.Log($"목표 키프레임 위치: {targetKeyframe.position}, 이동 시간: {moveDuration}");
|
||||
|
||||
Vector3[] startPositions = new Vector3[jointTransforms.Length];
|
||||
Quaternion[] startRotations = new Quaternion[jointTransforms.Length];
|
||||
for (int i = 0; i < jointTransforms.Length; i++)
|
||||
{
|
||||
startPositions[i] = jointTransforms[i].localPosition;
|
||||
startRotations[i] = jointTransforms[i].localRotation;
|
||||
}
|
||||
|
||||
List<Vector3> targetPositions = targetKeyframe.nodesPosition;
|
||||
List<Quaternion> targetRotations = targetKeyframe.nodesRotation;
|
||||
|
||||
if (moveDuration > 0f)
|
||||
{
|
||||
float elapsedTime = 0f;
|
||||
while (elapsedTime < moveDuration)
|
||||
{
|
||||
elapsedTime += Time.deltaTime;
|
||||
Debug.Log($"로봇 이동 중... 경과 시간: {elapsedTime:F2}s / {moveDuration:F2}s");
|
||||
float t = elapsedTime / moveDuration; // 이동 진행도 계산 (0.0 ~ 1.0)
|
||||
|
||||
for (int i = 0; i < jointTransforms.Length; i++)
|
||||
{
|
||||
jointTransforms[i].localPosition = Vector3.Lerp(startPositions[i], targetPositions[i], t);
|
||||
jointTransforms[i].localRotation = Quaternion.Slerp(startRotations[i], targetRotations[i], t);
|
||||
Debug.Log($"관절 {i} 위치: {jointTransforms[i].localPosition}, 회전: {jointTransforms[i].localRotation.eulerAngles}");
|
||||
}
|
||||
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 보간이 끝난 뒤 모든 관절을 목표 자세로 정확히 설정
|
||||
for (int i = 0; i < jointTransforms.Length; i++)
|
||||
{
|
||||
jointTransforms[i].localPosition = targetPositions[i];
|
||||
jointTransforms[i].localRotation = targetRotations[i];
|
||||
Debug.Log($"로봇 관절 {i} 최종 위치: {jointTransforms[i].localPosition}, 회전: {jointTransforms[i].localRotation.eulerAngles}");
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log("시뮬레이션 종료.");
|
||||
simulationCoroutine = null;
|
||||
SetButtonsInteractable(true);
|
||||
IKSolver.SetActive(true);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5ab9a95eb69cb82438fe98f7e4caada1
|
||||
@@ -90,7 +90,7 @@ MonoBehaviour:
|
||||
active: 1
|
||||
postExposure:
|
||||
m_OverrideState: 1
|
||||
m_Value: -1.33
|
||||
m_Value: -0.6
|
||||
contrast:
|
||||
m_OverrideState: 1
|
||||
m_Value: 20
|
||||
|
||||
@@ -15389,6 +15389,7 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier:
|
||||
view: {fileID: 1243479632}
|
||||
robotController: {fileID: 806304512143720359}
|
||||
motorStatePollInterval: 1
|
||||
--- !u!4 &1299890571
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -23658,7 +23659,7 @@ PrefabInstance:
|
||||
m_Modifications:
|
||||
- target: {fileID: 585480472500942354, guid: 77e7c27b2c5525e4aa8cc9f99d654486, type: 3}
|
||||
propertyPath: m_Antialiasing
|
||||
value: 1
|
||||
value: 3
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 585480472500942354, guid: 77e7c27b2c5525e4aa8cc9f99d654486, type: 3}
|
||||
propertyPath: m_RenderPostProcessing
|
||||
@@ -23864,6 +23865,10 @@ PrefabInstance:
|
||||
propertyPath: m_IsActive
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 7917674758326848207, guid: 77e7c27b2c5525e4aa8cc9f99d654486, type: 3}
|
||||
propertyPath: near clip plane
|
||||
value: 0.3
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 8806329115733545877, guid: 77e7c27b2c5525e4aa8cc9f99d654486, type: 3}
|
||||
propertyPath: m_Name
|
||||
value: XR Origin Hands (XR Rig)
|
||||
|
||||
Binary file not shown.
@@ -1,3 +1,4 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
@@ -7,6 +8,8 @@ public class AppManager : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private ProgramView view;
|
||||
[SerializeField] private RobotController robotController;
|
||||
[SerializeField] private float motorStatePollInterval = 1.0f;
|
||||
ProgramPresenter presenter;
|
||||
private string hostip;
|
||||
private int tcpPort;
|
||||
private int udpPort;
|
||||
@@ -19,10 +22,14 @@ public class AppManager : MonoBehaviour
|
||||
ProgramModel model = new ProgramModel(hostip, tcpPort);
|
||||
await model.InitializeAsync();
|
||||
|
||||
ProgramPresenter presenter = new ProgramPresenter(model, view);
|
||||
presenter = new ProgramPresenter(model, view);
|
||||
presenter.RegisterControlledRobot(robotController);
|
||||
|
||||
await presenter.UpdateMotorStateAsync();
|
||||
|
||||
view.DisplayProgram(null);
|
||||
|
||||
StartCoroutine(PollMotorStateCoroutine());
|
||||
}
|
||||
|
||||
private void LoadConfig()
|
||||
@@ -116,4 +123,14 @@ public class AppManager : MonoBehaviour
|
||||
udpPort = defaultPort;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator PollMotorStateCoroutine()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
yield return new WaitForSeconds(motorStatePollInterval);
|
||||
|
||||
_ = presenter.UpdateMotorStateAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,26 @@ public class ProgramModel : IProgramModel
|
||||
return modelName;
|
||||
}
|
||||
|
||||
public async Task<bool> GetRobotMotorStateAsync()
|
||||
{
|
||||
string requestUri = $"{baseUrl}/project/rgen";
|
||||
|
||||
HttpResponseMessage result = await httpClient.GetAsync(requestUri);
|
||||
string jsonResponse = await result.Content.ReadAsStringAsync();
|
||||
|
||||
JObject data = JObject.Parse(jsonResponse);
|
||||
|
||||
int motorState = (int)data.SelectToken("enable_state");
|
||||
if (motorState == 2 || motorState == 256)
|
||||
return true;
|
||||
else if (motorState == 1)
|
||||
return false;
|
||||
else
|
||||
{
|
||||
throw new Exception("로봇 상태 API 응답에서 모터 상태를 찾을 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> LoadProgram(string programId)
|
||||
{
|
||||
string requestUri = $"{baseUrl}/file_manager/files?pathname=project/jobs/{programId}&common";
|
||||
@@ -145,7 +165,7 @@ public class ProgramModel : IProgramModel
|
||||
|
||||
try
|
||||
{
|
||||
HttpResponseMessage result = await httpClient.GetAsync(new Uri($"{baseUrl}/project/jobs_info"));
|
||||
HttpResponseMessage result = await httpClient.GetAsync($"{baseUrl}/project/jobs_info");
|
||||
jsonResponse = await result.Content.ReadAsStringAsync();
|
||||
|
||||
wrappedJson = $"{{\"jobs\":{jsonResponse}}}";
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEngine.XR.ARSubsystems;
|
||||
|
||||
public class ProgramPresenter
|
||||
{
|
||||
@@ -7,6 +9,7 @@ public class ProgramPresenter
|
||||
private IProgramView view;
|
||||
private RobotController controlledRobot;
|
||||
private string _programId;
|
||||
private bool lastKnownMotorState = false;
|
||||
|
||||
public ProgramPresenter(ProgramModel model, IProgramView view)
|
||||
{
|
||||
@@ -26,12 +29,29 @@ public class ProgramPresenter
|
||||
this.controlledRobot = robot;
|
||||
}
|
||||
|
||||
public async Task UpdateMotorStateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
bool currentState = await model.GetRobotMotorStateAsync();
|
||||
|
||||
if (currentState != lastKnownMotorState)
|
||||
{
|
||||
controlledRobot.SetMotorState(currentState);
|
||||
lastKnownMotorState = currentState;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning($"모터 상태 업데이트 실패: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void OnApplicationStart()
|
||||
{
|
||||
if (controlledRobot != null)
|
||||
{
|
||||
Debug.Log("로봇 모터를 ON 상태로 설정합니다.");
|
||||
controlledRobot.SetMotorState(true);
|
||||
Debug.Log("제어할 로봇이 등록되었습니다.");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -43,7 +63,7 @@ public class ProgramPresenter
|
||||
{
|
||||
if (await model.CreateNewProgram(programId))
|
||||
{
|
||||
view.DisplayProgram(model.CurrentProgram);
|
||||
view.DisplayProgram(programId);
|
||||
view.HideProgramSelectPanel();
|
||||
OnApplicationStart();
|
||||
}
|
||||
@@ -68,7 +88,7 @@ public class ProgramPresenter
|
||||
{
|
||||
if(_programId != null && await model.LoadProgram(_programId))
|
||||
{
|
||||
view.DisplayProgram(model.CurrentProgram);
|
||||
view.DisplayProgram(_programId);
|
||||
view.HideProgramSelectPanel();
|
||||
view.HideProgramList();
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ using TMPro;
|
||||
public interface IProgramView
|
||||
{
|
||||
void ShowMessage(string message);
|
||||
void DisplayProgram(RobotProgram program);
|
||||
void DisplayProgram(string programId);
|
||||
void ShowProgramList(List<string> programIds);
|
||||
void HideProgramList();
|
||||
void HideProgramSelectPanel();
|
||||
@@ -107,16 +107,16 @@ public class ProgramView : MonoBehaviour, IProgramView
|
||||
Debug.LogWarning(message);
|
||||
}
|
||||
|
||||
public void DisplayProgram(RobotProgram program)
|
||||
public void DisplayProgram(string programId)
|
||||
{
|
||||
if (program == null)
|
||||
if (programId == null)
|
||||
{
|
||||
//currentProgramIdText.text = "No Program Loaded";
|
||||
//endpointListText.text = "";
|
||||
Debug.Log("No Program Loaded");
|
||||
return;
|
||||
}
|
||||
Debug.Log($"연결된 프로그램: {program.ProgramId}.job");
|
||||
Debug.Log($"연결된 프로그램: {programId}.job");
|
||||
|
||||
//currentProgramIdText.text = "Current: " + program.programId;
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ QualitySettings:
|
||||
terrainMaxTrees: 50
|
||||
excludedTargetPlatforms: []
|
||||
- serializedVersion: 4
|
||||
name: Ultra
|
||||
name: PC
|
||||
pixelLightCount: 1
|
||||
shadows: 2
|
||||
shadowResolution: 2
|
||||
@@ -285,7 +285,7 @@ QualitySettings:
|
||||
antiAliasing: 0
|
||||
softParticles: 1
|
||||
softVegetation: 1
|
||||
realtimeReflectionProbes: 1
|
||||
realtimeReflectionProbes: 0
|
||||
billboardsFaceCameraPosition: 1
|
||||
useLegacyDetailDistribution: 1
|
||||
adaptiveVsync: 0
|
||||
|
||||
Reference in New Issue
Block a user