From 93aff198f24b0a41009baaeb8245b9db0f3dda8d Mon Sep 17 00:00:00 2001 From: rootdarkarchon Date: Sun, 19 Jan 2025 16:19:06 +0100 Subject: [PATCH] Add GPose Together (#82) * add api glue * most of gpose together impl * more cleanup and impl * more impl * minor fixes and chara name abbreviations --------- Co-authored-by: Stanley Dimant --- MareAPI | 2 +- MareSynchronos/Interop/Ipc/IpcCallerBrio.cs | 2 +- MareSynchronos/Interop/VfxSpawnManager.cs | 40 +- MareSynchronos/Plugin.cs | 2 + .../CharaDataGposeTogetherManager.cs | 670 ++++++++++++++++++ .../Services/CharaData/CharaDataManager.cs | 52 +- .../CharaData/CharaDataTogetherManager.cs | 12 + .../CharaData/Models/GposeLobbyUserData.cs | 174 +++++ MareSynchronos/Services/Mediator/Messages.cs | 6 + .../UI/CharaDataHubUi.GposeTogether.cs | 227 ++++++ MareSynchronos/UI/CharaDataHubUi.cs | 16 +- MareSynchronos/UI/UISharedService.cs | 1 + .../ApiController.Functions.Callbacks.cs | 70 +- .../ApiController.Functions.CharaData.cs | 93 ++- .../WebAPI/SignalR/ApiController.cs | 6 + 15 files changed, 1351 insertions(+), 22 deletions(-) create mode 100644 MareSynchronos/Services/CharaData/CharaDataGposeTogetherManager.cs create mode 100644 MareSynchronos/Services/CharaData/CharaDataTogetherManager.cs create mode 100644 MareSynchronos/Services/CharaData/Models/GposeLobbyUserData.cs create mode 100644 MareSynchronos/UI/CharaDataHubUi.GposeTogether.cs diff --git a/MareAPI b/MareAPI index 4e4b2da..8b77956 160000 --- a/MareAPI +++ b/MareAPI @@ -1 +1 @@ -Subproject commit 4e4b2dab1774cb5fb7d7e05435eab3dd83112620 +Subproject commit 8b77956ec8620eb96f9f12b72182e0a6c70b23d1 diff --git a/MareSynchronos/Interop/Ipc/IpcCallerBrio.cs b/MareSynchronos/Interop/Ipc/IpcCallerBrio.cs index af8c7ad..b8a9c58 100644 --- a/MareSynchronos/Interop/Ipc/IpcCallerBrio.cs +++ b/MareSynchronos/Interop/Ipc/IpcCallerBrio.cs @@ -95,7 +95,7 @@ public sealed class IpcCallerBrio : IIpcCaller if (gameObject == null) return default; var data = await _dalamudUtilService.RunOnFrameworkThread(() => _brioGetModelTransform.InvokeFunc(gameObject)).ConfigureAwait(false); if (data.Item1 == null || data.Item2 == null || data.Item3 == null) return default; - _logger.LogDebug("Getting Transform from Actor {actor}", gameObject.Name.TextValue); + //_logger.LogDebug("Getting Transform from Actor {actor}", gameObject.Name.TextValue); return new WorldData() { diff --git a/MareSynchronos/Interop/VfxSpawnManager.cs b/MareSynchronos/Interop/VfxSpawnManager.cs index 59dde4a..7d7fe4f 100644 --- a/MareSynchronos/Interop/VfxSpawnManager.cs +++ b/MareSynchronos/Interop/VfxSpawnManager.cs @@ -39,7 +39,7 @@ public unsafe class VfxSpawnManager : DisposableMediatorSubscriberBase }); mareMediator.Subscribe(this, (msg) => { - ChangeSpawnVisibility(0.5f); + RestoreSpawnVisiblity(); }); mareMediator.Subscribe(this, (msg) => { @@ -47,19 +47,27 @@ public unsafe class VfxSpawnManager : DisposableMediatorSubscriberBase }); mareMediator.Subscribe(this, (msg) => { - ChangeSpawnVisibility(0.5f); + RestoreSpawnVisiblity(); }); } + private unsafe void RestoreSpawnVisiblity() + { + foreach (var vfx in _spawnedObjects) + { + ((VfxStruct*)vfx.Value.Address)->Alpha = vfx.Value.Visibility; + } + } + private unsafe void ChangeSpawnVisibility(float visibility) { foreach (var vfx in _spawnedObjects) { - ((VfxStruct*)vfx.Value)->Alpha = visibility; + ((VfxStruct*)vfx.Value.Address)->Alpha = visibility; } } - private readonly Dictionary _spawnedObjects = []; + private readonly Dictionary _spawnedObjects = []; private VfxStruct* SpawnStatic(string path, Vector3 pos, Quaternion rotation, float r, float g, float b, float a, Vector3 scale) { @@ -106,17 +114,29 @@ public unsafe class VfxSpawnManager : DisposableMediatorSubscriberBase Guid guid = Guid.NewGuid(); Logger.LogDebug("Spawned VFX at {pos}, {rot}: 0x{ptr:X}", position, rotation, (nint)vfx); - _spawnedObjects[guid] = (nint)vfx; + _spawnedObjects[guid] = ((nint)vfx, a); return guid; } - public void DespawnObject(Guid id) + public unsafe void MoveObject(Guid id, Vector3 newPosition) { - if (_spawnedObjects.Remove(id, out var vfx)) + if (_spawnedObjects.TryGetValue(id, out var vfxValue)) { - Logger.LogDebug("Despawning {obj:X}", vfx); - _staticVfxRemove((VfxStruct*)vfx); + if (vfxValue.Address == nint.Zero) return; + var vfx = (VfxStruct*)vfxValue.Address; + vfx->Position = newPosition with { Y = newPosition.Y + 1 }; + vfx->Flags |= 2; + } + } + + public void DespawnObject(Guid? id) + { + if (id == null) return; + if (_spawnedObjects.Remove(id.Value, out var value)) + { + Logger.LogDebug("Despawning {obj:X}", value.Address); + _staticVfxRemove((VfxStruct*)value.Address); } } @@ -125,7 +145,7 @@ public unsafe class VfxSpawnManager : DisposableMediatorSubscriberBase foreach (var obj in _spawnedObjects.Values) { Logger.LogDebug("Despawning {obj:X}", obj); - _staticVfxRemove((VfxStruct*)obj); + _staticVfxRemove((VfxStruct*)obj.Address); } } diff --git a/MareSynchronos/Plugin.cs b/MareSynchronos/Plugin.cs index ecce971..d3cdc66 100644 --- a/MareSynchronos/Plugin.cs +++ b/MareSynchronos/Plugin.cs @@ -25,6 +25,7 @@ using MareSynchronos.WebAPI.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using MareSynchronos.Services.CharaData; using MareSynchronos; @@ -118,6 +119,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); + collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); diff --git a/MareSynchronos/Services/CharaData/CharaDataGposeTogetherManager.cs b/MareSynchronos/Services/CharaData/CharaDataGposeTogetherManager.cs new file mode 100644 index 0000000..3d13987 --- /dev/null +++ b/MareSynchronos/Services/CharaData/CharaDataGposeTogetherManager.cs @@ -0,0 +1,670 @@ +using Dalamud.Game.ClientState.Objects.SubKinds; +using MareSynchronos.API.Data; +using MareSynchronos.API.Dto.CharaData; +using MareSynchronos.Interop; +using MareSynchronos.Interop.Ipc; +using MareSynchronos.Services.CharaData.Models; +using MareSynchronos.Services.Mediator; +using MareSynchronos.WebAPI; +using Microsoft.Extensions.Logging; +using System.Globalization; +using System.Numerics; +using System.Text.Json.Nodes; + +namespace MareSynchronos.Services.CharaData; + +public class CharaDataGposeTogetherManager : DisposableMediatorSubscriberBase +{ + private readonly ApiController _apiController; + private readonly IpcCallerBrio _brio; + private readonly SemaphoreSlim _charaDataCreationSemaphore = new(1, 1); + private readonly CharaDataFileHandler _charaDataFileHandler; + private readonly CharaDataManager _charaDataManager; + private readonly DalamudUtilService _dalamudUtil; + private readonly Dictionary _usersInLobby = []; + private readonly VfxSpawnManager _vfxSpawnManager; + private (CharacterData ApiData, CharaDataDownloadDto Dto)? _lastCreatedCharaData; + private PoseData? _lastDeltaPoseData; + private PoseData? _lastFullPoseData; + private WorldData? _lastWorldData; + private CancellationTokenSource _lobbyCts = new(); + private int _poseGenerationExecutions = 0; + + public CharaDataGposeTogetherManager(ILogger logger, MareMediator mediator, + ApiController apiController, IpcCallerBrio brio, DalamudUtilService dalamudUtil, VfxSpawnManager vfxSpawnManager, + CharaDataFileHandler charaDataFileHandler, CharaDataManager charaDataManager) : base(logger, mediator) + { + Mediator.Subscribe(this, (msg) => + { + OnUserJoinLobby(msg.UserData); + }); + Mediator.Subscribe(this, (msg) => + { + OnUserLeaveLobby(msg.UserData); + }); + Mediator.Subscribe(this, (msg) => + { + OnReceiveCharaData(msg.CharaDataDownloadDto); + }); + Mediator.Subscribe(this, (msg) => + { + OnReceivePoseData(msg.UserData, msg.PoseData); + }); + Mediator.Subscribe(this, (msg) => + { + OnReceiveWorldData(msg.UserData, msg.WorldData); + }); + Mediator.Subscribe(this, (msg) => + { + if (_usersInLobby.Count > 0 && !string.IsNullOrEmpty(CurrentGPoseLobbyId)) + { + JoinGPoseLobby(CurrentGPoseLobbyId, isReconnecting: true); + } + else + { + LeaveGPoseLobby(); + } + }); + Mediator.Subscribe(this, (msg) => + { + OnEnterGpose(); + }); + Mediator.Subscribe(this, (msg) => + { + OnExitGpose(); + }); + Mediator.Subscribe(this, (msg) => + { + OnFrameworkUpdate(); + }); + Mediator.Subscribe(this, (msg) => + { + OnCutsceneFrameworkUpdate(); + }); + Mediator.Subscribe(this, (msg) => + { + LeaveGPoseLobby(); + }); + + _apiController = apiController; + _brio = brio; + _dalamudUtil = dalamudUtil; + _vfxSpawnManager = vfxSpawnManager; + _charaDataFileHandler = charaDataFileHandler; + _charaDataManager = charaDataManager; + } + + public string? CurrentGPoseLobbyId { get; private set; } + public string? LastGPoseLobbyId { get; private set; } + + public IEnumerable UsersInLobby => _usersInLobby.Values; + + public (bool SameMap, bool SameServer, bool SameEverything) IsOnSameMapAndServer(GposeLobbyUserData data) + { + return (data.Map.RowId == _lastWorldData?.LocationInfo.MapId, data.WorldData?.LocationInfo.ServerId == _lastWorldData?.LocationInfo.ServerId, data.WorldData?.LocationInfo == _lastWorldData?.LocationInfo); + } + + public async Task PushCharacterDownloadDto() + { + var playerData = await _charaDataFileHandler.CreatePlayerData().ConfigureAwait(false); + if (playerData == null) return; + if (!string.Equals(playerData.DataHash.Value, _lastCreatedCharaData?.ApiData.DataHash.Value, StringComparison.Ordinal)) + { + List filegamePaths = [.. playerData.FileReplacements[API.Data.Enum.ObjectKind.Player] + .Where(u => string.IsNullOrEmpty(u.FileSwapPath)).SelectMany(u => u.GamePaths, (file, path) => new GamePathEntry(file.Hash, path))]; + List fileSwapPaths = [.. playerData.FileReplacements[API.Data.Enum.ObjectKind.Player] + .Where(u => !string.IsNullOrEmpty(u.FileSwapPath)).SelectMany(u => u.GamePaths, (file, path) => new GamePathEntry(file.FileSwapPath, path))]; + await _charaDataManager.UploadFiles([.. playerData.FileReplacements[API.Data.Enum.ObjectKind.Player] + .Where(u => string.IsNullOrEmpty(u.FileSwapPath)).SelectMany(u => u.GamePaths, (file, path) => new GamePathEntry(file.Hash, path))]) + .ConfigureAwait(false); + + CharaDataDownloadDto charaDataDownloadDto = new($"GPOSELOBBY:{CurrentGPoseLobbyId}", new(_apiController.UID)) + { + UpdatedDate = DateTime.UtcNow, + ManipulationData = playerData.ManipulationData, + CustomizeData = playerData.CustomizePlusData[API.Data.Enum.ObjectKind.Player], + FileGamePaths = filegamePaths, + FileSwaps = fileSwapPaths, + GlamourerData = playerData.GlamourerData[API.Data.Enum.ObjectKind.Player], + }; + + _lastCreatedCharaData = (playerData, charaDataDownloadDto); + } + + _lastFullPoseData = null; + _lastWorldData = null; + + if (_lastCreatedCharaData != null) + await _apiController.GposeLobbyPushCharacterData(_lastCreatedCharaData.Value.Dto) + .ConfigureAwait(false); + } + + internal void CreateNewLobby() + { + _ = Task.Run(async () => + { + ClearLobby(); + CurrentGPoseLobbyId = await _apiController.GposeLobbyCreate().ConfigureAwait(false); + if (!string.IsNullOrEmpty(CurrentGPoseLobbyId)) + { + _ = GposeWorldPositionBackgroundTask(_lobbyCts.Token); + _ = GposePoseDataBackgroundTask(_lobbyCts.Token); + } + }); + } + + internal void JoinGPoseLobby(string joinLobbyId, bool isReconnecting = false) + { + _ = Task.Run(async () => + { + var otherUsers = await _apiController.GposeLobbyJoin(joinLobbyId).ConfigureAwait(false); + ClearLobby(); + if (otherUsers.Any()) + { + LastGPoseLobbyId = string.Empty; + + foreach (var user in otherUsers) + { + OnUserJoinLobby(user); + } + + CurrentGPoseLobbyId = joinLobbyId; + _ = GposeWorldPositionBackgroundTask(_lobbyCts.Token); + _ = GposePoseDataBackgroundTask(_lobbyCts.Token); + } + else + { + LeaveGPoseLobby(); + LastGPoseLobbyId = string.Empty; + } + }); + } + + internal void LeaveGPoseLobby() + { + _ = Task.Run(async () => + { + var left = await _apiController.GposeLobbyLeave().ConfigureAwait(false); + if (left) + { + if (_usersInLobby.Count != 0) + { + LastGPoseLobbyId = CurrentGPoseLobbyId; + } + + ClearLobby(revertCharas: true); + } + }); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) + { + ClearLobby(revertCharas: true); + } + } + + private void ClearLobby(bool revertCharas = false) + { + _lobbyCts.Cancel(); + _lobbyCts.Dispose(); + _lobbyCts = new(); + CurrentGPoseLobbyId = string.Empty; + foreach (var user in _usersInLobby.ToDictionary()) + { + if (revertCharas) + _charaDataManager.RevertChara(user.Value.HandledChara); + OnUserLeaveLobby(user.Value.UserData); + } + _usersInLobby.Clear(); + } + + private string CreateJsonFromPoseData(PoseData? poseData) + { + if (poseData == null) return "{}"; + + var node = new JsonObject(); + node["Bones"] = new JsonObject(); + foreach (var bone in poseData.Value.Bones) + { + node["Bones"]![bone.Key] = new JsonObject(); + node["Bones"]![bone.Key]!["Position"] = $"{bone.Value.PositionX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.PositionY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.PositionZ.ToString(CultureInfo.InvariantCulture)}"; + node["Bones"]![bone.Key]!["Scale"] = $"{bone.Value.ScaleX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.ScaleY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.ScaleZ.ToString(CultureInfo.InvariantCulture)}"; + node["Bones"]![bone.Key]!["Rotation"] = $"{bone.Value.RotationX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationZ.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationW.ToString(CultureInfo.InvariantCulture)}"; + } + node["MainHand"] = new JsonObject(); + foreach (var bone in poseData.Value.MainHand) + { + node["MainHand"]![bone.Key] = new JsonObject(); + node["MainHand"]![bone.Key]!["Position"] = $"{bone.Value.PositionX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.PositionY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.PositionZ.ToString(CultureInfo.InvariantCulture)}"; + node["MainHand"]![bone.Key]!["Scale"] = $"{bone.Value.ScaleX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.ScaleY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.ScaleZ.ToString(CultureInfo.InvariantCulture)}"; + node["MainHand"]![bone.Key]!["Rotation"] = $"{bone.Value.RotationX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationZ.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationW.ToString(CultureInfo.InvariantCulture)}"; + } + node["OffHand"] = new JsonObject(); + foreach (var bone in poseData.Value.OffHand) + { + node["OffHand"]![bone.Key] = new JsonObject(); + node["OffHand"]![bone.Key]!["Position"] = $"{bone.Value.PositionX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.PositionY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.PositionZ.ToString(CultureInfo.InvariantCulture)}"; + node["OffHand"]![bone.Key]!["Scale"] = $"{bone.Value.ScaleX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.ScaleY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.ScaleZ.ToString(CultureInfo.InvariantCulture)}"; + node["OffHand"]![bone.Key]!["Rotation"] = $"{bone.Value.RotationX.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationY.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationZ.ToString(CultureInfo.InvariantCulture)}, {bone.Value.RotationW.ToString(CultureInfo.InvariantCulture)}"; + } + + Logger.LogTrace(node.ToJsonString(new System.Text.Json.JsonSerializerOptions() { WriteIndented = true })); + + return node.ToJsonString(); + } + + private PoseData CreatePoseDataFromJson(string json, PoseData? fullPoseData = null) + { + PoseData output = new(); + output.Bones = new(StringComparer.Ordinal); + output.MainHand = new(StringComparer.Ordinal); + output.OffHand = new(StringComparer.Ordinal); + + float getRounded(string number) + { + return float.Round(float.Parse(number, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture), 5); + } + + BoneData createBoneData(JsonNode boneJson) + { + BoneData outputBoneData = new(); + outputBoneData.Exists = true; + var posString = boneJson["Position"]!.ToString(); + var pos = posString.Split(",", StringSplitOptions.TrimEntries); + outputBoneData.PositionX = getRounded(pos[0]); + outputBoneData.PositionY = getRounded(pos[1]); + outputBoneData.PositionZ = getRounded(pos[2]); + + var scaString = boneJson["Scale"]!.ToString(); + var sca = scaString.Split(",", StringSplitOptions.TrimEntries); + outputBoneData.ScaleX = getRounded(sca[0]); + outputBoneData.ScaleY = getRounded(sca[1]); + outputBoneData.ScaleZ = getRounded(sca[2]); + + var rotString = boneJson["Rotation"]!.ToString(); + var rot = rotString.Split(",", StringSplitOptions.TrimEntries); + outputBoneData.RotationX = getRounded(rot[0]); + outputBoneData.RotationY = getRounded(rot[1]); + outputBoneData.RotationZ = getRounded(rot[2]); + outputBoneData.RotationW = getRounded(rot[3]); + return outputBoneData; + } + + var node = JsonNode.Parse(json)!; + var bones = node["Bones"]!.AsObject(); + foreach (var bone in bones) + { + string name = bone.Key; + var boneJson = bone.Value!.AsObject(); + BoneData outputBoneData = createBoneData(boneJson); + + if (fullPoseData != null) + { + if (fullPoseData.Value.Bones.TryGetValue(name, out var prevBoneData) && prevBoneData != outputBoneData) + { + output.Bones[name] = outputBoneData; + } + } + else + { + output.Bones[name] = outputBoneData; + } + } + var mainHand = node["MainHand"]!.AsObject(); + foreach (var bone in mainHand) + { + string name = bone.Key; + var boneJson = bone.Value!.AsObject(); + BoneData outputBoneData = createBoneData(boneJson); + + if (fullPoseData != null) + { + if (fullPoseData.Value.MainHand.TryGetValue(name, out var prevBoneData) && prevBoneData != outputBoneData) + { + output.MainHand[name] = outputBoneData; + } + } + else + { + output.MainHand[name] = outputBoneData; + } + } + var offhand = node["OffHand"]!.AsObject(); + foreach (var bone in offhand) + { + string name = bone.Key; + var boneJson = bone.Value!.AsObject(); + BoneData outputBoneData = createBoneData(boneJson); + + if (fullPoseData != null) + { + if (fullPoseData.Value.OffHand.TryGetValue(name, out var prevBoneData) && prevBoneData != outputBoneData) + { + output.OffHand[name] = outputBoneData; + } + } + else + { + output.OffHand[name] = outputBoneData; + } + } + + if (fullPoseData != null) + output.IsDelta = true; + + return output; + } + + private async Task GposePoseDataBackgroundTask(CancellationToken ct) + { + _lastFullPoseData = null; + _lastDeltaPoseData = null; + _poseGenerationExecutions = 0; + + while (!ct.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(10), ct).ConfigureAwait(false); + if (!_dalamudUtil.IsInGpose) continue; + if (_usersInLobby.Count == 0) continue; + + var chara = await _dalamudUtil.GetPlayerCharacterAsync().ConfigureAwait(false); + if (_dalamudUtil.IsInGpose) + { + chara = (IPlayerCharacter?)(await _dalamudUtil.GetGposeCharacterFromObjectTableByNameAsync(chara.Name.TextValue, _dalamudUtil.IsInGpose).ConfigureAwait(false)); + } + if (chara == null || chara.Address == nint.Zero) continue; + + var poseJson = await _brio.GetPoseAsync(chara.Address).ConfigureAwait(false); + if (string.IsNullOrEmpty(poseJson)) continue; + + var poseData = CreatePoseDataFromJson(poseJson, _poseGenerationExecutions++ >= 12 ? null : _lastFullPoseData); + if (!poseData.IsDelta) + { + _lastFullPoseData = poseData; + _lastDeltaPoseData = null; + _poseGenerationExecutions = 0; + } + + bool deltaIsSame = _lastDeltaPoseData != null && + (poseData.Bones.Keys.All(k => _lastDeltaPoseData.Value.Bones.ContainsKey(k) + && poseData.Bones.Values.All(k => _lastDeltaPoseData.Value.Bones.ContainsValue(k)))); + + if ((poseData.Bones.Any() || poseData.MainHand.Any() || poseData.OffHand.Any()) + && (!poseData.IsDelta || (poseData.IsDelta && !deltaIsSame))) + await _apiController.GposeLobbyPushPoseData(poseData).ConfigureAwait(false); + + if (poseData.IsDelta) + _lastDeltaPoseData = poseData; + } + } + + private async Task GposeWorldPositionBackgroundTask(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(_dalamudUtil.IsInGpose ? 10 : 1), ct).ConfigureAwait(false); + + // if there are no players in lobby, don't do anything + if (_usersInLobby.Count == 0) continue; + + // get own player data + var player = (Dalamud.Game.ClientState.Objects.Types.ICharacter?)(await _dalamudUtil.GetPlayerCharacterAsync().ConfigureAwait(false)); + if (player == null) continue; + WorldData worldData; + if (_dalamudUtil.IsInGpose) + { + player = await _dalamudUtil.GetGposeCharacterFromObjectTableByNameAsync(player.Name.TextValue, true).ConfigureAwait(false); + if (player == null) continue; + worldData = (await _brio.GetTransformAsync(player.Address).ConfigureAwait(false)); + } + else + { + var rotQuaternion = Quaternion.CreateFromAxisAngle(new Vector3(0, 1, 0), player.Rotation); + worldData = new() + { + PositionX = player.Position.X, + PositionY = player.Position.Y, + PositionZ = player.Position.Z, + RotationW = rotQuaternion.W, + RotationX = rotQuaternion.X, + RotationY = rotQuaternion.Y, + RotationZ = rotQuaternion.Z, + ScaleX = 1, + ScaleY = 1, + ScaleZ = 1 + }; + } + + var loc = await _dalamudUtil.GetMapDataAsync().ConfigureAwait(false); + worldData.LocationInfo = loc; + + if (worldData != _lastWorldData) + { + await _apiController.GposeLobbyPushWorldData(worldData).ConfigureAwait(false); + _lastWorldData = worldData; + Logger.LogTrace("WorldData (gpose: {gpose}): {data}", _dalamudUtil.IsInGpose, worldData); + } + + foreach (var entry in _usersInLobby) + { + if (!entry.Value.HasWorldDataUpdate || _dalamudUtil.IsInGpose) continue; + + var entryWorldData = entry.Value.WorldData!.Value; + + if (worldData.LocationInfo.MapId == entryWorldData.LocationInfo.MapId && worldData.LocationInfo.DivisionId == entryWorldData.LocationInfo.DivisionId + && (worldData.LocationInfo.HouseId != entryWorldData.LocationInfo.HouseId + || worldData.LocationInfo.WardId != entryWorldData.LocationInfo.WardId + || entryWorldData.LocationInfo.ServerId != worldData.LocationInfo.ServerId)) + { + if (entry.Value.SpawnedVfxId == null) + { + // spawn if it doesn't exist yet + entry.Value.LastWorldPosition = new Vector3(entryWorldData.PositionX, entryWorldData.PositionY, entryWorldData.PositionZ); + entry.Value.SpawnedVfxId = await _dalamudUtil.RunOnFrameworkThread(() => _vfxSpawnManager.SpawnObject(entry.Value.LastWorldPosition.Value, + Quaternion.Identity, Vector3.One, 0.5f, 0.1f, 0.5f, 0.9f)).ConfigureAwait(false); + } + else + { + // move object via lerp if it does exist + var newPosition = new Vector3(entryWorldData.PositionX, entryWorldData.PositionY, entryWorldData.PositionZ); + if (newPosition != entry.Value.LastWorldPosition) + { + entry.Value.UpdateStart = DateTime.UtcNow; + entry.Value.TargetWorldPosition = newPosition; + } + } + } + else + { + await _dalamudUtil.RunOnFrameworkThread(() => _vfxSpawnManager.DespawnObject(entry.Value.SpawnedVfxId)).ConfigureAwait(false); + entry.Value.SpawnedVfxId = null; + } + } + } + } + + private void OnCutsceneFrameworkUpdate() + { + foreach (var kvp in _usersInLobby) + { + if (!string.IsNullOrWhiteSpace(kvp.Value.AssociatedCharaName)) + { + kvp.Value.Address = _dalamudUtil.GetGposeCharacterFromObjectTableByName(kvp.Value.AssociatedCharaName, true)?.Address ?? nint.Zero; + if (kvp.Value.Address == nint.Zero) + { + kvp.Value.AssociatedCharaName = string.Empty; + } + } + + if (kvp.Value.Address != nint.Zero && (kvp.Value.HasWorldDataUpdate || kvp.Value.HasPoseDataUpdate)) + { + bool hadPoseDataUpdate = kvp.Value.HasPoseDataUpdate; + bool hadWorldDataUpdate = kvp.Value.HasWorldDataUpdate; + kvp.Value.HasPoseDataUpdate = false; + kvp.Value.HasWorldDataUpdate = false; + + _ = Task.Run(async () => + { + if (hadPoseDataUpdate && kvp.Value.ApplicablePoseData != null) + { + await _brio.SetPoseAsync(kvp.Value.Address, CreateJsonFromPoseData(kvp.Value.ApplicablePoseData)).ConfigureAwait(false); + } + if (hadWorldDataUpdate && kvp.Value.WorldData != null) + { + await _brio.ApplyTransformAsync(kvp.Value.Address, kvp.Value.WorldData.Value).ConfigureAwait(false); + } + }); + } + } + } + + private void OnEnterGpose() + { + ResetOwnData(); + foreach (var data in _usersInLobby.Values) + { + _ = _dalamudUtil.RunOnFrameworkThread(() => _vfxSpawnManager.DespawnObject(data.SpawnedVfxId)); + data.Reset(); + } + } + + private void OnExitGpose() + { + ResetOwnData(); + foreach (var data in _usersInLobby.Values) + { + data.Reset(); + } + } + + private void ResetOwnData() + { + _lastFullPoseData = null; + _lastDeltaPoseData = null; + _poseGenerationExecutions = 0; + _lastCreatedCharaData = null; + } + + private void OnFrameworkUpdate() + { + var frameworkTime = DateTime.UtcNow; + foreach (var kvp in _usersInLobby) + { + if (kvp.Value.SpawnedVfxId != null && kvp.Value.UpdateStart != null) + { + var secondsElasped = frameworkTime.Subtract(kvp.Value.UpdateStart.Value).TotalSeconds; + if (secondsElasped >= 1) + { + kvp.Value.LastWorldPosition = kvp.Value.TargetWorldPosition; + kvp.Value.TargetWorldPosition = null; + kvp.Value.UpdateStart = null; + } + else + { + var lerp = Vector3.Lerp(kvp.Value.LastWorldPosition ?? Vector3.One, kvp.Value.TargetWorldPosition ?? Vector3.One, (float)secondsElasped); + _vfxSpawnManager.MoveObject(kvp.Value.SpawnedVfxId.Value, lerp); + } + } + } + } + + private void OnReceiveCharaData(CharaDataDownloadDto charaDataDownloadDto) + { + if (!_usersInLobby.TryGetValue(charaDataDownloadDto.Uploader.UID, out var lobbyData)) + { + return; + } + + lobbyData.CharaData = charaDataDownloadDto; + if (lobbyData.Address != nint.Zero && !string.IsNullOrEmpty(lobbyData.AssociatedCharaName)) + { + _ = ApplyCharaData(lobbyData); + } + } + + public async Task ApplyCharaData(GposeLobbyUserData userData) + { + if (userData.CharaData == null || userData.Address == nint.Zero || string.IsNullOrEmpty(userData.AssociatedCharaName)) + return; + + await _charaDataCreationSemaphore.WaitAsync(_lobbyCts.Token).ConfigureAwait(false); + + try + { + await _charaDataManager.ApplyCharaData(userData.CharaData!, userData.AssociatedCharaName).ConfigureAwait(false); + userData.LastAppliedCharaDataDate = userData.CharaData.UpdatedDate; + userData.HasPoseDataUpdate = true; + userData.HasWorldDataUpdate = true; + } + finally + { + _charaDataCreationSemaphore.Release(); + } + } + + private readonly SemaphoreSlim _charaDataSpawnSemaphore = new(1, 1); + + internal async Task SpawnAndApplyData(GposeLobbyUserData userData) + { + if (userData.CharaData == null) + return; + + await _charaDataSpawnSemaphore.WaitAsync(_lobbyCts.Token).ConfigureAwait(false); + try + { + userData.HasPoseDataUpdate = false; + userData.HasWorldDataUpdate = false; + var chara = await _charaDataManager.SpawnAndApplyData(userData.CharaData).ConfigureAwait(false); + if (chara == null) return; + userData.HandledChara = chara; + userData.AssociatedCharaName = chara.Name; + userData.HasPoseDataUpdate = true; + userData.HasWorldDataUpdate = true; + } + finally + { + _charaDataSpawnSemaphore.Release(); + } + } + + private void OnReceivePoseData(UserData userData, PoseData poseData) + { + if (!_usersInLobby.TryGetValue(userData.UID, out var lobbyData)) + { + return; + } + + if (poseData.IsDelta) + lobbyData.DeltaPoseData = poseData; + else + lobbyData.FullPoseData = poseData; + } + + private void OnReceiveWorldData(UserData userData, WorldData worldData) + { + _usersInLobby[userData.UID].WorldData = worldData; + _ = _usersInLobby[userData.UID].SetWorldDataDescriptor(_dalamudUtil); + } + + private void OnUserJoinLobby(UserData userData) + { + if (_usersInLobby.ContainsKey(userData.UID)) + OnUserLeaveLobby(userData); + _usersInLobby[userData.UID] = new(userData); + _lastFullPoseData = null; + _lastWorldData = null; + _ = PushCharacterDownloadDto(); + } + + private void OnUserLeaveLobby(UserData msg) + { + _usersInLobby.Remove(msg.UID, out var existingData); + if (existingData != default) + { + _ = _dalamudUtil.RunOnFrameworkThread(() => _vfxSpawnManager.DespawnObject(existingData.SpawnedVfxId)); + } + } +} diff --git a/MareSynchronos/Services/CharaData/CharaDataManager.cs b/MareSynchronos/Services/CharaData/CharaDataManager.cs index 94f6e1a..ac145c1 100644 --- a/MareSynchronos/Services/CharaData/CharaDataManager.cs +++ b/MareSynchronos/Services/CharaData/CharaDataManager.cs @@ -12,6 +12,7 @@ using MareSynchronos.Services.Mediator; using MareSynchronos.Utils; using MareSynchronos.WebAPI; using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; using System.Text; namespace MareSynchronos.Services; @@ -23,7 +24,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase private readonly DalamudUtilService _dalamudUtilService; private readonly CharaDataFileHandler _fileHandler; private readonly IpcManager _ipcManager; - private readonly Dictionary _metaInfoCache = []; + private readonly ConcurrentDictionary _metaInfoCache = []; private readonly List _nearbyData = []; private readonly CharaDataNearbyManager _nearbyManager; private readonly CharaDataCharacterHandler _characterHandler; @@ -106,6 +107,23 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase public Task<(string Output, bool Success)>? UploadTask { get; set; } public bool BrioAvailable => _ipcManager.Brio.APIAvailable; + public Task ApplyCharaData(CharaDataDownloadDto dataDownloadDto, string charaName) + { + return UiBlockingComputation = DataApplicationTask = Task.Run(async () => + { + if (string.IsNullOrEmpty(charaName)) return; + + CharaDataMetaInfoDto metaInfo = new(dataDownloadDto.Id, dataDownloadDto.Uploader) + { + CanBeDownloaded = true, + Description = $"Data from {dataDownloadDto.Uploader.AliasOrUID} for {dataDownloadDto.Id}", + UpdatedDate = dataDownloadDto.UpdatedDate, + }; + + await DownloadAndAplyDataAsync(charaName, dataDownloadDto, metaInfo, false).ConfigureAwait(false); + }); + } + public Task ApplyCharaData(CharaDataMetaInfoDto dataMetaInfoDto, string charaName) { return UiBlockingComputation = DataApplicationTask = Task.Run(async () => @@ -300,7 +318,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase if (ret) { _ownCharaData.Remove(dto.Id); - _metaInfoCache.Remove(dto.FullId); + _metaInfoCache.Remove(dto.FullId, out _); } DistributeMetaInfo(); } @@ -341,7 +359,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase { foreach (var data in _ownCharaData) { - _metaInfoCache.Remove(data.Key); + _metaInfoCache.Remove(data.Key, out _); } _ownCharaData.Clear(); UiBlockingComputation = GetAllDataTask = Task.Run(async () => @@ -518,6 +536,22 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase }); } + public Task SpawnAndApplyData(CharaDataDownloadDto charaDataDownloadDto) + { + var task = Task.Run(async () => + { + var newActor = await _ipcManager.Brio.SpawnActorAsync().ConfigureAwait(false); + if (newActor == null) return null; + await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + + await ApplyCharaData(charaDataDownloadDto, newActor.Name.TextValue).ConfigureAwait(false); + + return _characterHandler.HandledCharaData.FirstOrDefault(f => string.Equals(f.Name, newActor.Name.TextValue, StringComparison.Ordinal)); + }); + UiBlockingComputation = task; + return task; + } + public Task SpawnAndApplyData(CharaDataMetaInfoDto charaDataMetaInfoDto) { var task = Task.Run(async () => @@ -560,10 +594,14 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase return extended; } + private readonly SemaphoreSlim _distributionSemaphore = new(1, 1); + private void DistributeMetaInfo() { - _nearbyManager.UpdateSharedData(_metaInfoCache); - _characterHandler.UpdateHandledData(_metaInfoCache); + _distributionSemaphore.Wait(); + _nearbyManager.UpdateSharedData(_metaInfoCache.ToDictionary()); + _characterHandler.UpdateHandledData(_metaInfoCache.ToDictionary()); + _distributionSemaphore.Release(); } private void CacheData(CharaDataMetaInfoExtendedDto charaData) @@ -841,7 +879,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase if (!_dalamudUtilService.IsInGpose) Mediator.Publish(new HaltCharaDataCreation(Resume: true)); - if (_configService.Current.FavoriteCodes.TryGetValue(metaInfo.Uploader.UID + ":" + metaInfo.Id, out var favorite) && favorite != null) + if (metaInfo != null && _configService.Current.FavoriteCodes.TryGetValue(metaInfo.Uploader.UID + ":" + metaInfo.Id, out var favorite) && favorite != null) { favorite.LastDownloaded = DateTime.UtcNow; _configService.Save(); @@ -932,7 +970,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase charaDataDownloadDto.CustomizeData, token).ConfigureAwait(false); } - private async Task<(string Result, bool Success)> UploadFiles(List missingFileList, Func? postUpload = null) + public async Task<(string Result, bool Success)> UploadFiles(List missingFileList, Func? postUpload = null) { UploadProgress = new ValueProgress(); try diff --git a/MareSynchronos/Services/CharaData/CharaDataTogetherManager.cs b/MareSynchronos/Services/CharaData/CharaDataTogetherManager.cs new file mode 100644 index 0000000..74b5500 --- /dev/null +++ b/MareSynchronos/Services/CharaData/CharaDataTogetherManager.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MareSynchronos.Services.CharaData +{ + internal class CharaDataTogetherManager + { + } +} diff --git a/MareSynchronos/Services/CharaData/Models/GposeLobbyUserData.cs b/MareSynchronos/Services/CharaData/Models/GposeLobbyUserData.cs new file mode 100644 index 0000000..ff12398 --- /dev/null +++ b/MareSynchronos/Services/CharaData/Models/GposeLobbyUserData.cs @@ -0,0 +1,174 @@ +using Dalamud.Utility; +using MareSynchronos.API.Data; +using MareSynchronos.API.Dto.CharaData; +using MareSynchronos.Utils; +using System.Globalization; +using System.Numerics; +using System.Text; + +namespace MareSynchronos.Services.CharaData.Models; + +public sealed record GposeLobbyUserData(UserData UserData) +{ + public void Reset() + { + HasWorldDataUpdate = WorldData != null; + HasPoseDataUpdate = ApplicablePoseData != null; + SpawnedVfxId = null; + LastAppliedCharaDataDate = DateTime.MinValue; + } + + private WorldData? _worldData; + public WorldData? WorldData + { + get => _worldData; set + { + _worldData = value; + HasWorldDataUpdate = true; + } + } + + public bool HasWorldDataUpdate { get; set; } = false; + + private PoseData? _fullPoseData; + private PoseData? _deltaPoseData; + + public PoseData? FullPoseData + { + get => _fullPoseData; + set + { + _fullPoseData = value; + ApplicablePoseData = CombinePoseData(); + HasPoseDataUpdate = true; + } + } + + public PoseData? DeltaPoseData + { + get => _deltaPoseData; + set + { + _deltaPoseData = value; + ApplicablePoseData = CombinePoseData(); + HasPoseDataUpdate = true; + } + } + + public PoseData? ApplicablePoseData { get; private set; } + public bool HasPoseDataUpdate { get; set; } = false; + public Guid? SpawnedVfxId { get; set; } + public Vector3? LastWorldPosition { get; set; } + public Vector3? TargetWorldPosition { get; set; } + public DateTime? UpdateStart { get; set; } + private CharaDataDownloadDto? _charaData; + public CharaDataDownloadDto? CharaData + { + get => _charaData; set + { + _charaData = value; + LastUpdatedCharaData = _charaData?.UpdatedDate ?? DateTime.MaxValue; + } + } + + public DateTime LastUpdatedCharaData { get; private set; } = DateTime.MaxValue; + public DateTime LastAppliedCharaDataDate { get; set; } = DateTime.MinValue; + public nint Address { get; set; } + public string AssociatedCharaName { get; set; } = string.Empty; + + private PoseData? CombinePoseData() + { + if (DeltaPoseData == null && FullPoseData != null) return FullPoseData; + if (FullPoseData == null) return null; + + PoseData output = FullPoseData!.Value.DeepClone(); + PoseData delta = DeltaPoseData!.Value; + + foreach (var bone in FullPoseData!.Value.Bones) + { + if (!delta.Bones.TryGetValue(bone.Key, out var data)) continue; + if (!data.Exists) + { + output.Bones.Remove(bone.Key); + } + else + { + output.Bones[bone.Key] = data; + } + } + + foreach (var bone in FullPoseData!.Value.MainHand) + { + if (!delta.MainHand.TryGetValue(bone.Key, out var data)) continue; + if (!data.Exists) + { + output.MainHand.Remove(bone.Key); + } + else + { + output.MainHand[bone.Key] = data; + } + } + + foreach (var bone in FullPoseData!.Value.OffHand) + { + if (!delta.OffHand.TryGetValue(bone.Key, out var data)) continue; + if (!data.Exists) + { + output.OffHand.Remove(bone.Key); + } + else + { + output.OffHand[bone.Key] = data; + } + } + + return output; + } + + public string WorldDataDescriptor { get; private set; } = string.Empty; + public Vector2 MapCoordinates { get; private set; } + public Lumina.Excel.Sheets.Map Map { get; private set; } + public HandledCharaDataEntry? HandledChara { get; set; } + + public async Task SetWorldDataDescriptor(DalamudUtilService dalamudUtilService) + { + if (WorldData == null) + { + WorldDataDescriptor = "No World Data found"; + } + + var worldData = WorldData!.Value; + MapCoordinates = await dalamudUtilService.RunOnFrameworkThread(() => + MapUtil.WorldToMap(new Vector2(worldData.PositionX, worldData.PositionY), dalamudUtilService.MapData.Value[worldData.LocationInfo.MapId].Map)) + .ConfigureAwait(false); + Map = dalamudUtilService.MapData.Value[worldData.LocationInfo.MapId].Map; + + StringBuilder sb = new(); + sb.AppendLine("Server: " + dalamudUtilService.WorldData.Value[(ushort)worldData.LocationInfo.ServerId]); + sb.AppendLine("Territory: " + dalamudUtilService.TerritoryData.Value[worldData.LocationInfo.TerritoryId]); + sb.AppendLine("Map: " + dalamudUtilService.MapData.Value[worldData.LocationInfo.MapId].MapName); + + if (worldData.LocationInfo.WardId != 0) + sb.AppendLine("Ward #: " + worldData.LocationInfo.WardId); + if (worldData.LocationInfo.DivisionId != 0) + { + sb.AppendLine("Subdivision: " + worldData.LocationInfo.DivisionId switch + { + 1 => "No", + 2 => "Yes", + _ => "-" + }); + } + if (worldData.LocationInfo.HouseId != 0) + { + sb.AppendLine("House #: " + (worldData.LocationInfo.HouseId == 100 ? "Apartments" : worldData.LocationInfo.HouseId.ToString())); + } + if (worldData.LocationInfo.RoomId != 0) + { + sb.AppendLine("Apartment #: " + worldData.LocationInfo.RoomId); + } + sb.AppendLine("Coordinates: X: " + MapCoordinates.X.ToString("0.0", CultureInfo.InvariantCulture) + ", Y: " + MapCoordinates.Y.ToString("0.0", CultureInfo.InvariantCulture)); + WorldDataDescriptor = sb.ToString(); + } +} diff --git a/MareSynchronos/Services/Mediator/Messages.cs b/MareSynchronos/Services/Mediator/Messages.cs index a64f7da..d314de3 100644 --- a/MareSynchronos/Services/Mediator/Messages.cs +++ b/MareSynchronos/Services/Mediator/Messages.cs @@ -1,6 +1,7 @@ using Dalamud.Game.ClientState.Objects.Types; using MareSynchronos.API.Data; using MareSynchronos.API.Dto; +using MareSynchronos.API.Dto.CharaData; using MareSynchronos.API.Dto.Group; using MareSynchronos.MareConfiguration.Models; using MareSynchronos.PlayerData.Handlers; @@ -101,6 +102,11 @@ public record GameObjectHandlerCreatedMessage(GameObjectHandler GameObjectHandle public record GameObjectHandlerDestroyedMessage(GameObjectHandler GameObjectHandler, bool OwnedObject) : MessageBase; public record HaltCharaDataCreation(bool Resume = false) : SameThreadMessage; public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase; +public record GposeLobbyUserJoin(UserData UserData) : MessageBase; +public record GPoseLobbyUserLeave(UserData UserData) : MessageBase; +public record GPoseLobbyReceiveCharaData(CharaDataDownloadDto CharaDataDownloadDto) : MessageBase; +public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) : MessageBase; +public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase; public record PluginChangeMessage(string InternalName, Version Version, bool IsLoaded) : KeyedMessage(InternalName); #pragma warning restore S2094 diff --git a/MareSynchronos/UI/CharaDataHubUi.GposeTogether.cs b/MareSynchronos/UI/CharaDataHubUi.GposeTogether.cs new file mode 100644 index 0000000..dab9e52 --- /dev/null +++ b/MareSynchronos/UI/CharaDataHubUi.GposeTogether.cs @@ -0,0 +1,227 @@ +using Dalamud.Interface; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using ImGuiNET; +using MareSynchronos.Services.CharaData.Models; + +namespace MareSynchronos.UI; + +internal sealed partial class CharaDataHubUi +{ + private string _joinLobbyId = string.Empty; + private void DrawGposeTogether() + { + if (!_charaDataManager.BrioAvailable) + { + ImGuiHelpers.ScaledDummy(5); + UiSharedService.DrawGroupedCenteredColorText("BRIO IS MANDATORY FOR GPOSE TOGETHER.", ImGuiColors.DalamudRed); + ImGuiHelpers.ScaledDummy(5); + } + + if (!_uiSharedService.ApiController.IsConnected) + { + ImGuiHelpers.ScaledDummy(5); + UiSharedService.DrawGroupedCenteredColorText("CANNOT USE GPOSE TOGETHER WHILE DISCONNECTED FROM THE SERVER.", ImGuiColors.DalamudRed); + ImGuiHelpers.ScaledDummy(5); + } + + _uiSharedService.BigText("GPose Together"); + DrawHelpFoldout("GPose together is a way to do multiplayer GPose sessions and collaborations." + UiSharedService.DoubleNewLine + + "GPose together requires Brio to function. Only Brio is also supported for the actual posing interactions. Attempting to pose using other tools will lead to conflicts and exploding characters." + UiSharedService.DoubleNewLine + + "To use GPose together you either create or join a GPose Together Lobby. After you and other people have joined, make sure that everyone is on the same map. " + + "It is not required for you to be on the same server, DC or instance. Users that are on the same map will be drawn as moving purple wisps in the overworld, so you can easily find each other." + UiSharedService.DoubleNewLine + + "Once you are close to each other you can initiate GPose. You must either assign or spawn characters for each of the lobby users. Their own poses and positions to their character will be automatically applied." + Environment.NewLine + + "Pose and location data during GPose are updated approximately every 10-20s."); + + using var disabled = ImRaii.Disabled(!_charaDataManager.BrioAvailable || !_uiSharedService.ApiController.IsConnected); + + UiSharedService.DistanceSeparator(); + _uiSharedService.BigText("Lobby Controls"); + if (string.IsNullOrEmpty(_charaDataGposeTogetherManager.CurrentGPoseLobbyId)) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, "Create New GPose Together Lobby")) + { + _charaDataGposeTogetherManager.CreateNewLobby(); + } + ImGuiHelpers.ScaledDummy(5); + ImGui.SetNextItemWidth(250); + ImGui.InputTextWithHint("##lobbyId", "GPose Lobby Id", ref _joinLobbyId, 30); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Join GPose Together Lobby")) + { + _charaDataGposeTogetherManager.JoinGPoseLobby(_joinLobbyId); + _joinLobbyId = string.Empty; + } + if (!string.IsNullOrEmpty(_charaDataGposeTogetherManager.LastGPoseLobbyId) + && _uiSharedService.IconTextButton(FontAwesomeIcon.LongArrowAltRight, $"Rejoin Last Lobby {_charaDataGposeTogetherManager.LastGPoseLobbyId}")) + { + _charaDataGposeTogetherManager.JoinGPoseLobby(_charaDataGposeTogetherManager.LastGPoseLobbyId); + } + } + else + { + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("GPose Lobby"); + ImGui.SameLine(); + UiSharedService.ColorTextWrapped(_charaDataGposeTogetherManager.CurrentGPoseLobbyId, ImGuiColors.ParsedGreen); + ImGui.SameLine(); + if (_uiSharedService.IconButton(FontAwesomeIcon.Clipboard)) + { + ImGui.SetClipboardText(_charaDataGposeTogetherManager.CurrentGPoseLobbyId); + } + UiSharedService.AttachToolTip("Copy Lobby ID to clipboard."); + using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowLeft, "Leave GPose Lobby")) + { + _charaDataGposeTogetherManager.LeaveGPoseLobby(); + } + } + UiSharedService.AttachToolTip("Leave the current GPose lobby." + UiSharedService.TooltipSeparator + "Hold CTRL and click to leave."); + } + UiSharedService.DistanceSeparator(); + using (ImRaii.Disabled(string.IsNullOrEmpty(_charaDataGposeTogetherManager.CurrentGPoseLobbyId))) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowUp, "Send Updated Character Data")) + { + _ = _charaDataGposeTogetherManager.PushCharacterDownloadDto(); + } + UiSharedService.AttachToolTip("This will send your current appearance, pose and world data to all users in the lobby."); + if (!_uiSharedService.IsInGpose) + { + ImGuiHelpers.ScaledDummy(5); + UiSharedService.DrawGroupedCenteredColorText("Assigning users to characters is only available in GPose.", ImGuiColors.DalamudYellow, 300); + } + UiSharedService.DistanceSeparator(); + ImGui.TextUnformatted("Users In Lobby"); + var gposeCharas = _dalamudUtilService.GetGposeCharactersFromObjectTable(); + var self = _dalamudUtilService.GetPlayerCharacter(); + gposeCharas = gposeCharas.Where(c => c != null && !string.Equals(c.Name.TextValue, self.Name.TextValue, StringComparison.Ordinal)).ToList(); + + using (ImRaii.Child("charaChild", new(0, 0), false, ImGuiWindowFlags.AlwaysAutoResize)) + { + ImGuiHelpers.ScaledDummy(3); + + if (!_charaDataGposeTogetherManager.UsersInLobby.Any() && !string.IsNullOrEmpty(_charaDataGposeTogetherManager.CurrentGPoseLobbyId)) + { + UiSharedService.DrawGroupedCenteredColorText("No other users in current GPose lobby", ImGuiColors.DalamudYellow); + } + else + { + foreach (var user in _charaDataGposeTogetherManager.UsersInLobby) + { + DrawLobbyUser(user, gposeCharas); + } + } + } + } + } + + private void DrawLobbyUser(GposeLobbyUserData user, + IEnumerable gposeCharas) + { + using var id = ImRaii.PushId(user.UserData.UID); + using var indent = ImRaii.PushIndent(5f); + var sameMapAndServer = _charaDataGposeTogetherManager.IsOnSameMapAndServer(user); + var width = ImGui.GetContentRegionAvail().X - 5; + UiSharedService.DrawGrouped(() => + { + var availWidth = ImGui.GetContentRegionAvail().X; + ImGui.AlignTextToFramePadding(); + var note = _serverConfigurationManager.GetNoteForUid(user.UserData.UID); + var userText = note == null ? user.UserData.AliasOrUID : $"{note} ({user.UserData.AliasOrUID})"; + UiSharedService.ColorText(userText, ImGuiColors.ParsedGreen); + + var buttonsize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.ArrowRight).X; + var buttonsize2 = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Plus).X; + ImGui.SameLine(); + ImGui.SetCursorPosX(availWidth - (buttonsize + buttonsize2 + ImGui.GetStyle().ItemSpacing.X)); + using (ImRaii.Disabled(!_uiSharedService.IsInGpose || user.CharaData == null || user.Address == nint.Zero)) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.ArrowRight)) + { + _ = _charaDataGposeTogetherManager.ApplyCharaData(user); + } + } + UiSharedService.AttachToolTip("Apply newly received character data to selected actor." + UiSharedService.TooltipSeparator + "Note: If the button is grayed out, the latest data has already been applied."); + ImGui.SameLine(); + using (ImRaii.Disabled(!_uiSharedService.IsInGpose || user.CharaData == null || sameMapAndServer.SameEverything)) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Plus)) + { + _ = _charaDataGposeTogetherManager.SpawnAndApplyData(user); + } + } + UiSharedService.AttachToolTip("Spawn new actor, apply character data and and assign it to this user." + UiSharedService.TooltipSeparator + "Note: If the button is grayed out, " + + "the user has not sent any character data or you are on the same map, server and instance. If the latter is the case, join a group with that user and assign the character to them."); + + + using (ImRaii.Group()) + { + UiSharedService.ColorText("Map Info", ImGuiColors.DalamudGrey); + ImGui.SameLine(); + _uiSharedService.IconText(FontAwesomeIcon.ExternalLinkSquareAlt, ImGuiColors.DalamudGrey); + } + UiSharedService.AttachToolTip(user.WorldDataDescriptor + UiSharedService.TooltipSeparator); + + ImGui.SameLine(); + _uiSharedService.IconText(FontAwesomeIcon.Map, sameMapAndServer.SameMap ? ImGuiColors.ParsedGreen : ImGuiColors.DalamudRed); + if (ImGui.IsItemClicked(ImGuiMouseButton.Left) && user.WorldData != null) + { + _dalamudUtilService.SetMarkerAndOpenMap(new(user.WorldData.Value.PositionX, user.WorldData.Value.PositionY, user.WorldData.Value.PositionZ), user.Map); + } + UiSharedService.AttachToolTip((sameMapAndServer.SameMap ? "You are on the same map." : "You are not on the same map.") + UiSharedService.TooltipSeparator + + "Note: Click to open the users location on your map." + Environment.NewLine + + "Note: For GPose synchronization to work properly, you must be on the same map."); + + ImGui.SameLine(); + _uiSharedService.IconText(FontAwesomeIcon.Globe, sameMapAndServer.SameServer ? ImGuiColors.ParsedGreen : ImGuiColors.DalamudRed); + UiSharedService.AttachToolTip((sameMapAndServer.SameMap ? "You are on the same server." : "You are not on the same server.") + UiSharedService.TooltipSeparator + + "Note: GPose synchronization is not dependent on the current server, but you will have to spawn a character for the other lobby users."); + + ImGui.SameLine(); + _uiSharedService.IconText(FontAwesomeIcon.Running, sameMapAndServer.SameEverything ? ImGuiColors.ParsedGreen : ImGuiColors.DalamudRed); + UiSharedService.AttachToolTip(sameMapAndServer.SameEverything ? "You are in the same instanced area." : "You are not the same instanced area." + UiSharedService.TooltipSeparator + + "Note: Users not in your instance, but on the same map, will be drawn as floating wisps." + Environment.NewLine + + "Note: GPose synchronization is not dependent on the current instance, but you will have to spawn a character for the other lobby users."); + + using (ImRaii.Disabled(!_uiSharedService.IsInGpose)) + { + ImGui.SetNextItemWidth(200); + using (var combo = ImRaii.Combo("##character", string.IsNullOrEmpty(user.AssociatedCharaName) ? "No character assigned" : CharaName(user.AssociatedCharaName))) + { + if (combo) + { + foreach (var chara in gposeCharas) + { + if (chara == null) continue; + + if (ImGui.Selectable(CharaName(chara.Name.TextValue), chara.Address == user.Address)) + { + user.AssociatedCharaName = chara.Name.TextValue; + user.Address = chara.Address; + } + } + } + } + ImGui.SameLine(); + using (ImRaii.Disabled(user.Address == nint.Zero)) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Trash)) + { + user.AssociatedCharaName = string.Empty; + user.Address = nint.Zero; + } + } + UiSharedService.AttachToolTip("Unassign Actor for this user"); + if (_uiSharedService.IsInGpose && user.Address == nint.Zero) + { + ImGui.SameLine(); + _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, ImGuiColors.DalamudRed); + UiSharedService.AttachToolTip("No valid character assigned for this user. Pose data will not be applied."); + } + } + }, 5, width); + ImGuiHelpers.ScaledDummy(5); + } +} diff --git a/MareSynchronos/UI/CharaDataHubUi.cs b/MareSynchronos/UI/CharaDataHubUi.cs index 461eca1..7ddb6d0 100644 --- a/MareSynchronos/UI/CharaDataHubUi.cs +++ b/MareSynchronos/UI/CharaDataHubUi.cs @@ -9,6 +9,7 @@ using MareSynchronos.MareConfiguration; using MareSynchronos.MareConfiguration.Models; using MareSynchronos.PlayerData.Pairs; using MareSynchronos.Services; +using MareSynchronos.Services.CharaData; using MareSynchronos.Services.CharaData.Models; using MareSynchronos.Services.Mediator; using MareSynchronos.Services.ServerConfiguration; @@ -26,6 +27,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase private readonly DalamudUtilService _dalamudUtilService; private readonly FileDialogManager _fileDialogManager; private readonly PairManager _pairManager; + private readonly CharaDataGposeTogetherManager _charaDataGposeTogetherManager; private readonly ServerConfigurationManager _serverConfigurationManager; private readonly UiSharedService _uiSharedService; private CancellationTokenSource _closalCts = new(); @@ -75,7 +77,8 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase public CharaDataHubUi(ILogger logger, MareMediator mediator, PerformanceCollectorService performanceCollectorService, CharaDataManager charaDataManager, CharaDataNearbyManager charaDataNearbyManager, CharaDataConfigService configService, UiSharedService uiSharedService, ServerConfigurationManager serverConfigurationManager, - DalamudUtilService dalamudUtilService, FileDialogManager fileDialogManager, PairManager pairManager) + DalamudUtilService dalamudUtilService, FileDialogManager fileDialogManager, PairManager pairManager, + CharaDataGposeTogetherManager charaDataGposeTogetherManager) : base(logger, mediator, "Loporrit Character Data Hub###LoporritCharaDataUI", performanceCollectorService) { SetWindowSizeConstraints(); @@ -88,6 +91,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase _dalamudUtilService = dalamudUtilService; _fileDialogManager = fileDialogManager; _pairManager = pairManager; + _charaDataGposeTogetherManager = charaDataGposeTogetherManager; Mediator.Subscribe(this, (_) => IsOpen |= _configService.Current.OpenMareHubOnGposeStart); Mediator.Subscribe(this, (msg) => { @@ -202,6 +206,16 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase _isHandlingSelf = _charaDataManager.HandledCharaData.Any(c => c.IsSelf); if (_isHandlingSelf) _openMcdOnlineOnNextRun = false; + using (var gposeTogetherTabItem = ImRaii.TabItem("GPose Together")) + { + if (gposeTogetherTabItem) + { + smallUi = true; + + DrawGposeTogether(); + } + } + using (var applicationTabItem = ImRaii.TabItem("Data Application", _openDataApplicationShared ? ImGuiTabItemFlags.SetSelected : ImGuiTabItemFlags.None)) { if (applicationTabItem) diff --git a/MareSynchronos/UI/UISharedService.cs b/MareSynchronos/UI/UISharedService.cs index 63f7aac..8182b0b 100644 --- a/MareSynchronos/UI/UISharedService.cs +++ b/MareSynchronos/UI/UISharedService.cs @@ -31,6 +31,7 @@ namespace MareSynchronos.UI; public partial class UiSharedService : DisposableMediatorSubscriberBase { public const string TooltipSeparator = "--SEP--"; + public static string DoubleNewLine => Environment.NewLine + Environment.NewLine; public static readonly ImGuiWindowFlags PopupWindowFlags = ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoScrollbar | diff --git a/MareSynchronos/WebAPI/SignalR/ApiController.Functions.Callbacks.cs b/MareSynchronos/WebAPI/SignalR/ApiController.Functions.Callbacks.cs index b3f2cd0..662c0e6 100644 --- a/MareSynchronos/WebAPI/SignalR/ApiController.Functions.Callbacks.cs +++ b/MareSynchronos/WebAPI/SignalR/ApiController.Functions.Callbacks.cs @@ -1,12 +1,15 @@ -using MareSynchronos.API.Data.Enum; +using MareSynchronos.API.Data; +using MareSynchronos.API.Data.Enum; using MareSynchronos.API.Dto; using MareSynchronos.API.Dto.Chat; +using MareSynchronos.API.Dto.CharaData; using MareSynchronos.API.Dto.Group; using MareSynchronos.API.Dto.User; using MareSynchronos.MareConfiguration.Models; using MareSynchronos.Services.Mediator; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Logging; +using static FFXIVClientStructs.FFXIV.Client.Game.UI.MapMarkerData.Delegates; namespace MareSynchronos.WebAPI; @@ -191,6 +194,41 @@ public partial class ApiController return Task.CompletedTask; } + public Task Client_GposeLobbyJoin(UserData userData) + { + Logger.LogDebug("Client_GposeLobbyJoin: {dto}", userData); + ExecuteSafely(() => Mediator.Publish(new GposeLobbyUserJoin(userData))); + return Task.CompletedTask; + } + + public Task Client_GposeLobbyLeave(UserData userData) + { + Logger.LogDebug("Client_GposeLobbyLeave: {dto}", userData); + ExecuteSafely(() => Mediator.Publish(new GPoseLobbyUserLeave(userData))); + return Task.CompletedTask; + } + + public Task Client_GposeLobbyPushCharacterData(CharaDataDownloadDto charaDownloadDto) + { + Logger.LogDebug("Client_GposeLobbyPushCharacterData: {dto}", charaDownloadDto.Uploader); + ExecuteSafely(() => Mediator.Publish(new GPoseLobbyReceiveCharaData(charaDownloadDto))); + return Task.CompletedTask; + } + + public Task Client_GposeLobbyPushPoseData(UserData userData, PoseData poseData) + { + Logger.LogDebug("Client_GposeLobbyPushPoseData: {dto}", userData); + ExecuteSafely(() => Mediator.Publish(new GPoseLobbyReceivePoseData(userData, poseData))); + return Task.CompletedTask; + } + + public Task Client_GposeLobbyPushWorldData(UserData userData, WorldData worldData) + { + //Logger.LogDebug("Client_GposeLobbyPushWorldData: {dto}", userData); + ExecuteSafely(() => Mediator.Publish(new GPoseLobbyReceiveWorldData(userData, worldData))); + return Task.CompletedTask; + } + public void OnDownloadReady(Action act) { if (_initialized) return; @@ -323,6 +361,36 @@ public partial class ApiController _mareHub!.On(nameof(Client_UserUpdateSelfPairPermissions), act); } + public void OnGposeLobbyJoin(Action act) + { + if (_initialized) return; + _mareHub!.On(nameof(Client_GposeLobbyJoin), act); + } + + public void OnGposeLobbyLeave(Action act) + { + if (_initialized) return; + _mareHub!.On(nameof(Client_GposeLobbyLeave), act); + } + + public void OnGposeLobbyPushCharacterData(Action act) + { + if (_initialized) return; + _mareHub!.On(nameof(Client_GposeLobbyPushCharacterData), act); + } + + public void OnGposeLobbyPushPoseData(Action act) + { + if (_initialized) return; + _mareHub!.On(nameof(Client_GposeLobbyPushPoseData), act); + } + + public void OnGposeLobbyPushWorldData(Action act) + { + if (_initialized) return; + _mareHub!.On(nameof(Client_GposeLobbyPushWorldData), act); + } + private void ExecuteSafely(Action act) { try diff --git a/MareSynchronos/WebAPI/SignalR/ApiController.Functions.CharaData.cs b/MareSynchronos/WebAPI/SignalR/ApiController.Functions.CharaData.cs index 1110827..eaa95e8 100644 --- a/MareSynchronos/WebAPI/SignalR/ApiController.Functions.CharaData.cs +++ b/MareSynchronos/WebAPI/SignalR/ApiController.Functions.CharaData.cs @@ -1,6 +1,5 @@ using MareSynchronos.API.Data; using MareSynchronos.API.Dto.CharaData; -using MareSynchronos.Services.CharaData.Models; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Logging; @@ -134,4 +133,96 @@ public partial class ApiController return null; } } + + public async Task GposeLobbyCreate() + { + if (!IsConnected) return string.Empty; + + try + { + Logger.LogDebug("Creating GPose Lobby"); + return await _mareHub!.InvokeAsync(nameof(GposeLobbyCreate)).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to create GPose lobby"); + return string.Empty; + } + } + + public async Task GposeLobbyLeave() + { + if (!IsConnected) return true; + + try + { + Logger.LogDebug("Leaving current GPose Lobby"); + return await _mareHub!.InvokeAsync(nameof(GposeLobbyLeave)).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to leave GPose lobby"); + return false; + } + } + + public async Task> GposeLobbyJoin(string lobbyId) + { + if (!IsConnected) return []; + + try + { + Logger.LogDebug("Joining GPose Lobby {id}", lobbyId); + return await _mareHub!.InvokeAsync>(nameof(GposeLobbyJoin), lobbyId).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to join GPose lobby {id}", lobbyId); + return []; + } + } + + public async Task GposeLobbyPushCharacterData(CharaDataDownloadDto charaDownloadDto) + { + if (!IsConnected) return; + + try + { + Logger.LogDebug("Sending Chara Data to GPose Lobby"); + await _mareHub!.InvokeAsync(nameof(GposeLobbyPushCharacterData), charaDownloadDto).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to send Chara Data to GPose lobby"); + } + } + + public async Task GposeLobbyPushPoseData(PoseData poseData) + { + if (!IsConnected) return; + + try + { + Logger.LogDebug("Sending Pose Data to GPose Lobby"); + await _mareHub!.InvokeAsync(nameof(GposeLobbyPushPoseData), poseData).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to send Pose Data to GPose lobby"); + } + } + + public async Task GposeLobbyPushWorldData(WorldData worldData) + { + if (!IsConnected) return; + + try + { + await _mareHub!.InvokeAsync(nameof(GposeLobbyPushWorldData), worldData).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to send World Data to GPose lobby"); + } + } } diff --git a/MareSynchronos/WebAPI/SignalR/ApiController.cs b/MareSynchronos/WebAPI/SignalR/ApiController.cs index ac90cbc..ed94cec 100644 --- a/MareSynchronos/WebAPI/SignalR/ApiController.cs +++ b/MareSynchronos/WebAPI/SignalR/ApiController.cs @@ -364,6 +364,12 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IM OnUserChatMsg((dto) => _ = Client_UserChatMsg(dto)); OnGroupChatMsg((dto) => _ = Client_GroupChatMsg(dto)); + OnGposeLobbyJoin((dto) => _ = Client_GposeLobbyJoin(dto)); + OnGposeLobbyLeave((dto) => _ = Client_GposeLobbyLeave(dto)); + OnGposeLobbyPushCharacterData((dto) => _ = Client_GposeLobbyPushCharacterData(dto)); + OnGposeLobbyPushPoseData((dto, data) => _ = Client_GposeLobbyPushPoseData(dto, data)); + OnGposeLobbyPushWorldData((dto, data) => _ = Client_GposeLobbyPushWorldData(dto, data)); + _healthCheckTokenSource?.Cancel(); _healthCheckTokenSource?.Dispose(); _healthCheckTokenSource = new CancellationTokenSource();