diff --git a/ShareBubbles/Main.cs b/ShareBubbles/Main.cs new file mode 100644 index 0000000..72f3c3d --- /dev/null +++ b/ShareBubbles/Main.cs @@ -0,0 +1,94 @@ +using MelonLoader; +using NAK.ShareBubbles.Networking; +using UnityEngine; + +namespace NAK.ShareBubbles; + +public class ShareBubblesMod : MelonMod +{ + internal static MelonLogger.Instance Logger; + + #region Melon Mod Overrides + + public override void OnInitializeMelon() + { + Logger = LoggerInstance; + + ShareBubbleManager.Initialize(); + TempShareManager.Initialize(); + + ModNetwork.Initialize(); + ModNetwork.Subscribe(); + + //ModSettings.Initialize(); + + ApplyPatches(typeof(Patches.PlayerSetup_Patches)); + ApplyPatches(typeof(Patches.ControllerRay_Patches)); + ApplyPatches(typeof(Patches.ViewManager_Patches)); + + LoadAssetBundle(); + } + + public override void OnApplicationQuit() + { + ModNetwork.Unsubscribe(); + } + + #endregion Melon Mod Overrides + + #region Melon Mod Utilities + + private void ApplyPatches(Type type) + { + try + { + HarmonyInstance.PatchAll(type); + } + catch (Exception e) + { + LoggerInstance.Msg($"Failed while patching {type.Name}!"); + LoggerInstance.Error(e); + } + } + + #endregion Melon Mod Utilities + + #region Asset Bundle Loading + + private const string SharingBubbleAssets = "ShareBubbles.Resources.sharingbubble.assets"; + private const string SharingBubblePrefabPath = "Assets/Mods/SharingBubble/SharingBubble.prefab"; + + internal static GameObject SharingBubblePrefab; + + private void LoadAssetBundle() + { + LoggerInstance.Msg($"Loading required asset bundle..."); + using Stream resourceStream = MelonAssembly.Assembly.GetManifestResourceStream(SharingBubbleAssets); + using MemoryStream memoryStream = new(); + if (resourceStream == null) { + LoggerInstance.Error($"Failed to load {SharingBubbleAssets}!"); + return; + } + + resourceStream.CopyTo(memoryStream); + AssetBundle assetBundle = AssetBundle.LoadFromMemory(memoryStream.ToArray()); + if (assetBundle == null) { + LoggerInstance.Error($"Failed to load {SharingBubbleAssets}! Asset bundle is null!"); + return; + } + + SharingBubblePrefab = assetBundle.LoadAsset(SharingBubblePrefabPath); + if (SharingBubblePrefab == null) { + LoggerInstance.Error($"Failed to load {SharingBubblePrefab}! Prefab is null!"); + return; + } + SharingBubblePrefab.hideFlags |= HideFlags.DontUnloadUnusedAsset; + LoggerInstance.Msg($"Loaded {SharingBubblePrefab}!"); + + // load + + LoggerInstance.Msg("Asset bundle successfully loaded!"); + } + + #endregion Asset Bundle Loading +} \ No newline at end of file diff --git a/ShareBubbles/ModSettings.cs b/ShareBubbles/ModSettings.cs new file mode 100644 index 0000000..22f708c --- /dev/null +++ b/ShareBubbles/ModSettings.cs @@ -0,0 +1,51 @@ +using MelonLoader; + +namespace NAK.ShareBubbles; + +// TODO: +// Setting for ShareBubbles scaling with player size +// Setting for receiving notification when a direct share is received +// Setting for ShareBubble being interactable outside of the UI buttons (Grab & Click) +// Store last Visibility, Lifetime, and Access Control settings for ShareBubble placement in hidden melon preferences + +public static class ModSettings +{ + #region Constants & Category + + internal const string ModName = nameof(ShareBubbles); // TODO idea: BTKUI player page button to remove player's ShareBubbles ? + + //internal const string SM_SettingsCategory = "Share Bubbles Mod"; + + private static readonly MelonPreferences_Category Category = + MelonPreferences.CreateCategory(ModName); + + #endregion Constants & Category + + #region Debug Settings + + internal static readonly MelonPreferences_Entry Debug_NetworkInbound = + Category.CreateEntry("debug_inbound", false, display_name: "Debug Inbound", description: "Log inbound Mod Network updates."); + + internal static readonly MelonPreferences_Entry Debug_NetworkOutbound = + Category.CreateEntry("debug_outbound", false, display_name: "Debug Outbound", description: "Log outbound Mod Network updates."); + + #endregion Debug Settings + + #region Initialization + + internal static void Initialize() + { + + } + + #endregion Initialization + + #region Setting Changed Callbacks + + // private static void OnPlayerUpAlignmentThresholdChanged(float oldValue, float newValue) + // { + // Entry_PlayerUpAlignmentThreshold.Value = Mathf.Clamp(newValue, 0f, 180f); + // } + + #endregion Setting Changed Callbacks +} \ No newline at end of file diff --git a/ShareBubbles/Patches.cs b/ShareBubbles/Patches.cs new file mode 100644 index 0000000..4f705e3 --- /dev/null +++ b/ShareBubbles/Patches.cs @@ -0,0 +1,1731 @@ +using ABI_RC.Core.InteractionSystem; +using ABI_RC.Core.Networking.API.Responses; +using ABI_RC.Core.Networking.API.UserWebsocket; +using ABI_RC.Core.Player; +using ABI_RC.Core.Savior; +using HarmonyLib; +using MTJobSystem; +using NAK.ShareBubbles.API; +using NAK.ShareBubbles.API.Exceptions; +using NAK.ShareBubbles.API.Responses; +using Newtonsoft.Json; + +namespace NAK.ShareBubbles.Patches; + +internal static class PlayerSetup_Patches +{ + [HarmonyPostfix] + [HarmonyPatch(typeof(PlayerSetup), nameof(PlayerSetup.SetSafeScale))] + public static void Postfix_PlayerSetup_SetSafeScale() + { + // I wish there was a callback to listen for player scale changes + ShareBubbleManager.Instance.OnPlayerScaleChanged(); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(PlayerSetup), nameof(PlayerSetup.GetCurrentPropSelectionMode))] + private static void Postfix_PlayerSetup_GetCurrentPropSelectionMode(ref PlayerSetup.PropSelectionMode __result) + { + // Stickers mod uses invalid enum value 4, so we use 5 + // https://github.com/NotAKidoS/NAK_CVR_Mods/blob/d0c8298074c4dcfc089ccb34ed8b8bd7e0b9cedf/Stickers/Patches.cs#L17 + if (ShareBubbleManager.Instance.IsPlacingBubbleMode) __result = (PlayerSetup.PropSelectionMode)5; + } +} + +internal static class ControllerRay_Patches +{ + [HarmonyPostfix] + [HarmonyPatch(typeof(ControllerRay), nameof(ControllerRay.DeleteSpawnable))] + public static void Postfix_ControllerRay_DeleteSpawnable(ref ControllerRay __instance) + { + if (!__instance._interactDown) + return; // not interacted, no need to check + + if (PlayerSetup.Instance.GetCurrentPropSelectionMode() + != PlayerSetup.PropSelectionMode.Delete) + return; // not in delete mode, no need to check + + ShareBubble shareBubble = __instance.hitTransform.GetComponentInParent(); + if (shareBubble == null) return; + + ShareBubbleManager.Instance.DestroyBubble(shareBubble); + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(ControllerRay), nameof(ControllerRay.HandlePropSpawn))] + private static void Prefix_ControllerRay_HandlePropSpawn(ref ControllerRay __instance) + { + if (!ShareBubbleManager.Instance.IsPlacingBubbleMode) + return; + + if (__instance._gripDown) ShareBubbleManager.Instance.IsPlacingBubbleMode = false; + if (__instance._hitUIInternal || !__instance._interactDown) + return; + + ShareBubbleManager.Instance.PlaceSelectedBubbleFromControllerRay(__instance.rayDirectionTransform); + } +} + +internal static class ViewManager_Patches +{ + +private const string DETAILS_TOOLBAR_PATCHES = """ + +const ContentShareMod = { + debugMode: false, + currentContentData: null, + themeColors: null, + + /* Theme Handling */ + + getThemeColors: function() { + if (this.themeColors) return this.themeColors; + + // Default fallback colors + const defaultColors = { + background: '#373021', + border: '#59885d' + }; + + // Try to get colors from favorite category element + const favoriteCategoryElement = document.querySelector('.favorite-category-selection'); + if (!favoriteCategoryElement) return defaultColors; + + const computedStyle = window.getComputedStyle(favoriteCategoryElement); + this.themeColors = { + background: computedStyle.backgroundColor || defaultColors.background, + border: computedStyle.borderColor || defaultColors.border + }; + + return this.themeColors; + }, + + applyThemeToDialog: function(dialog) { + const colors = this.getThemeColors(); + dialog.style.backgroundColor = colors.background; + dialog.style.borderColor = colors.border; + + // Update any close or page buttons to match theme + const buttons = dialog.querySelectorAll('.close-btn, .page-btn'); + buttons.forEach(button => { + button.style.borderColor = colors.border; + }); + + return colors; + }, + + /* Core Initialization */ + + init: function() { + const styles = [ + this.getSharedStyles(), + this.ShareBubble.initStyles(), + this.ShareSelect.initStyles(), + this.DirectShare.initStyles(), + this.Unshare.initStyles() + ].join('\n'); + + const styleElement = document.createElement('style'); + styleElement.type = 'text/css'; + styleElement.innerHTML = styles; + document.head.appendChild(styleElement); + + this.shareBubbleDialog = this.ShareBubble.createDialog(); + this.shareSelectDialog = this.ShareSelect.createDialog(); + this.directShareDialog = this.DirectShare.createDialog(); + this.unshareDialog = this.Unshare.createDialog(); + + this.initializeToolbars(); + this.bindEvents(); + }, + + getSharedStyles: function() { + return ` + .content-sharing-base-dialog { + position: fixed; + background-color: #373021; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 800px; + min-width: 500px; + border: 3px solid #59885d; + padding: 20px; + z-index: 100000; + opacity: 0; + transition: opacity 0.2s linear; + } + + .content-sharing-base-dialog.in { + opacity: 1; + } + + .content-sharing-base-dialog.out { + opacity: 0; + } + + .content-sharing-base-dialog.hidden { + display: none; + } + + .content-sharing-base-dialog h2, + .content-sharing-base-dialog h3 { + margin-top: 0; + margin-bottom: 0.5em; + text-align: left; + } + + .content-sharing-base-dialog .description { + margin-bottom: 1em; + text-align: left; + font-size: 0.9em; + color: #aaa; + } + + .content-sharing-base-dialog .close-btn { + position: absolute; + top: 1%; + right: 1%; + border-radius: 0.25em; + border: 3px solid #59885d; + padding: 0.5em; + width: 8em; + text-align: center; + } + + .page-btn { + border-radius: 0.25em; + border: 3px solid #59885d; + padding: 0.5em; + width: 8em; + text-align: center; + } + + .page-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .inp-hidden { + display: none; + } + `; + }, + + /* Feature Modules */ + + ShareBubble: { + initStyles: function() { + return ` + .share-bubble-dialog { + max-width: 800px; + transform: translate(-50%, -60%); + } + + .share-bubble-dialog .content-btn { + position: relative; + margin-bottom: 0.5em; + } + + .share-bubble-dialog .btn-group { + display: flex; + margin-bottom: 1em; + } + + .share-bubble-dialog .option-select { + flex: 1 1 0; + min-width: 0; + background-color: inherit; + text-align: center; + padding: 0.5em; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid inherit; + } + + .share-bubble-dialog .option-select + .option-select { + margin-left: -1px; + } + + .share-bubble-dialog .option-select.active, + .share-bubble-dialog .option-select:hover { + background-color: rgba(27, 80, 55, 1); + } + + .share-bubble-dialog .action-buttons { + margin-top: 1em; + display: flex; + justify-content: space-between; + } + + .share-bubble-dialog .action-btn { + flex: 1; + text-align: center; + padding: 0.5em; + border-radius: 0.25em; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + margin-right: 0.5em; + } + + .share-bubble-dialog .action-btn:last-child { + margin-right: 0; + } + `; + }, + + createDialog: function() { + const dialog = document.createElement('div'); + dialog.id = 'content-share-bubble-dialog'; + dialog.className = 'content-sharing-base-dialog share-bubble-dialog hidden'; + dialog.innerHTML = ` +

Share Bubble

+
Close
+ +

Visibility

+

Choose who can see your Sharing Bubble.

+
+
Everyone
+
Friends Only
+
+ +

Lifetime

+

How long the Sharing Bubble lasts. You can delete it at any time.

+
+
2 Minutes
+
For Session
+
+ +
+

Access Control

+
+ + +

+ Users cannot access your Private Content through this share. +

+
+
+
Keep
+
Instance Only
+
None
+
+
+ + + + + + + +
+
Drop
+
Select
+
+ `; + document.body.appendChild(dialog); + return dialog; + }, + + show: function(contentDetails) { + const dialog = ContentShareMod.shareBubbleDialog; + const colors = ContentShareMod.applyThemeToDialog(dialog); + + // Additional ShareBubble-specific theming + const optionSelects = dialog.querySelectorAll('.option-select'); + optionSelects.forEach(element => { + element.style.borderColor = colors.border; + }); + + const grantAccessSection = dialog.querySelector('.grant-access-section'); + const noAccessControlMessage = dialog.querySelector('.no-access-control-message'); + const showGrantAccess = contentDetails.IsMine && !contentDetails.IsPublic; + + if (grantAccessSection && noAccessControlMessage) { + grantAccessSection.style.display = showGrantAccess ? '' : 'none'; + noAccessControlMessage.style.display = showGrantAccess ? 'none' : ''; + } + + dialog.classList.remove('hidden', 'out'); + setTimeout(() => dialog.classList.add('in'), 50); + + ContentShareMod.currentContentData = contentDetails; + }, + + hide: function() { + const dialog = ContentShareMod.shareBubbleDialog; + dialog.classList.remove('in'); + dialog.classList.add('out'); + setTimeout(() => { + dialog.classList.add('hidden'); + dialog.classList.remove('out'); + }, 200); + }, + + changeVisibility: function(element) { + document.getElementById('share-visibility').value = element.dataset.visibilityValue; + const buttons = ContentShareMod.shareBubbleDialog.querySelectorAll('.visibility-btn'); + buttons.forEach(btn => btn.classList.remove('active')); + element.classList.add('active'); + }, + + changeDuration: function(element) { + document.getElementById('share-duration').value = element.dataset.durationValue; + const buttons = ContentShareMod.shareBubbleDialog.querySelectorAll('.duration-btn'); + buttons.forEach(btn => btn.classList.remove('active')); + element.classList.add('active'); + }, + + changeAccess: function(element) { + document.getElementById('share-access').value = element.dataset.accessValue; + const buttons = ContentShareMod.shareBubbleDialog.querySelectorAll('.access-btn'); + buttons.forEach(btn => btn.classList.remove('active')); + element.classList.add('active'); + + const descriptions = ContentShareMod.shareBubbleDialog.querySelectorAll('.access-desc'); + descriptions.forEach(desc => { + desc.style.display = desc.dataset.accessType === element.dataset.accessValue ? '' : 'none'; + }); + }, + + submit: function(action) { + const contentDetails = ContentShareMod.currentContentData; + let bubbleImpl, bubbleContent, contentImage, contentName; + + if (contentDetails.AvatarId) { + bubbleImpl = 'Avatar'; + bubbleContent = contentDetails.AvatarId; + contentImage = contentDetails.AvatarImageCoui; + contentName = contentDetails.AvatarName; + } else if (contentDetails.SpawnableId) { + bubbleImpl = 'Spawnable'; + bubbleContent = contentDetails.SpawnableId; + contentImage = contentDetails.SpawnableImageCoui; + contentName = contentDetails.SpawnableName; + } else if (contentDetails.WorldId) { + bubbleImpl = 'World'; + bubbleContent = contentDetails.WorldId; + contentImage = contentDetails.WorldImageCoui; + contentName = contentDetails.WorldName; + } else if (contentDetails.UserId) { + bubbleImpl = 'User'; + bubbleContent = contentDetails.UserId; + contentImage = contentDetails.UserImageCoui; + contentName = contentDetails.UserName; + } else { + console.error('No valid content ID found'); + return; + } + + const visibility = document.getElementById('share-visibility').value; + const duration = document.getElementById('share-duration').value; + const access = document.getElementById('share-access').value; + + const shareRule = visibility === 'FriendsOnly' ? 'FriendsOnly' : 'Everyone'; + const shareLifetime = duration === 'Session' ? 'Session' : 'TwoMinutes'; + const shareAccess = access === 'Permanent' ? 'Permanent' : + access === 'Session' ? 'Session' : 'NoAccess'; + + if (ContentShareMod.debugMode) { + console.log('Sharing content:', { + action, bubbleImpl, bubbleContent, + shareRule, shareLifetime, shareAccess + }); + } + + engine.call('NAKCallShareContent', action, bubbleImpl, bubbleContent, + shareRule, shareLifetime, shareAccess, contentImage, contentName); + + this.hide(); + } + }, + + Unshare: { + currentPage: 1, + totalPages: 1, + sharesPerPage: 5, + sharesList: null, + + initStyles: function() { + return ` + .unshare-dialog { + width: 800px; + height: 1000px; + transform: translate(-50%, -60%); + display: flex; + flex-direction: column; + } + + .unshare-dialog .shares-container { + flex: 1; + overflow-y: auto; + margin: 20px 0; + min-height: 0; + } + + .unshare-dialog #shares-loading, + .unshare-dialog #shares-error, + .unshare-dialog #shares-empty { + text-align: center; + padding: 2em; + font-size: 1.1em; + } + + .unshare-dialog #shares-error { + color: #ff6b6b; + } + + .unshare-dialog .share-item { + display: flex; + align-items: center; + padding: 15px; + border: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 10px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + min-height: 120px; + } + + .unshare-dialog .share-item img { + width: 96px; + height: 96px; + border-radius: 4px; + margin-right: 15px; + cursor: pointer; + } + + .unshare-dialog .share-item .user-name { + flex: 1; + font-size: 1.3em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 15px; + cursor: pointer; + } + + .unshare-dialog .action-btn { + width: 180px; + height: 60px; + font-size: 1.2em; + color: white; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 10px; + padding: 0 20px; + text-align: center; + } + + .unshare-dialog .revoke-btn { + background-color: #ff6b6b; + } + + .unshare-dialog .undo-btn { + background-color: #4a9eff; + } + + .unshare-dialog .action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + background-color: #666; + } + + .unshare-dialog .pagination { + border-top: 1px solid rgba(255, 255, 255, 0.1); + padding-top: 20px; + } + + .unshare-dialog .page-info { + font-size: 1.1em; + opacity: 0.8; + margin-bottom: 15px; + text-align: center; + } + + .unshare-dialog .page-buttons { + display: flex; + justify-content: center; + gap: 20px; + } + + .unshare-dialog .share-item.revoked { + opacity: 0.7; + } + `; + }, + + createDialog: function() { + const dialog = document.createElement('div'); + dialog.id = 'content-unshare-dialog'; + dialog.className = 'content-sharing-base-dialog unshare-dialog hidden'; + dialog.innerHTML = ` +

Manage Shares

+
Close
+ +
+
Loading shares...
+ + + +
+ + + `; + document.body.appendChild(dialog); + return dialog; + }, + + show: function(contentDetails) { + const dialog = ContentShareMod.unshareDialog; + ContentShareMod.applyThemeToDialog(dialog); + + dialog.classList.remove('hidden', 'out'); + setTimeout(() => dialog.classList.add('in'), 50); + + ContentShareMod.currentContentData = contentDetails; + this.currentPage = 1; + this.totalPages = 1; + this.sharesList = null; + this.requestShares(); + }, + + hide: function() { + const dialog = ContentShareMod.unshareDialog; + dialog.classList.remove('in'); + dialog.classList.add('out'); + setTimeout(() => { + dialog.classList.add('hidden'); + dialog.classList.remove('out'); + }, 200); + }, + + requestShares: function() { + const dialog = ContentShareMod.unshareDialog; + const sharesContainer = dialog.querySelector('.shares-container'); + + sharesContainer.querySelector('#shares-loading').style.display = ''; + sharesContainer.querySelector('#shares-error').style.display = 'none'; + sharesContainer.querySelector('#shares-empty').style.display = 'none'; + sharesContainer.querySelector('#shares-list').style.display = 'none'; + + const contentDetails = ContentShareMod.currentContentData; + const contentType = contentDetails.AvatarId ? 'Avatar' : 'Spawnable'; + const contentId = contentDetails.AvatarId || contentDetails.SpawnableId; + + engine.call('NAKGetContentShares', contentType, contentId); + }, + + handleSharesResponse: function(success, shares) { + const dialog = ContentShareMod.unshareDialog; + const sharesContainer = dialog.querySelector('.shares-container'); + const loadingElement = sharesContainer.querySelector('#shares-loading'); + const errorElement = sharesContainer.querySelector('#shares-error'); + const emptyElement = sharesContainer.querySelector('#shares-empty'); + const sharesListElement = sharesContainer.querySelector('#shares-list'); + + loadingElement.style.display = 'none'; + + if (!success) { + errorElement.style.display = ''; + return; + } + + try { + const response = JSON.parse(shares); + this.sharesList = response.Data.value; + + if (!this.sharesList || this.sharesList.length === 0) { + emptyElement.style.display = ''; + const pagination = dialog.querySelector('.pagination'); + const [prevButton, nextButton] = pagination.querySelectorAll('.page-btn'); + prevButton.disabled = true; + nextButton.disabled = true; + pagination.querySelector('.page-info').textContent = '1/1'; + return; + } + + this.totalPages = Math.ceil(this.sharesList.length / this.sharesPerPage); + this.updatePageContent(); + } catch (error) { + console.error('Error parsing shares:', error); + errorElement.style.display = ''; + } + }, + + updatePageContent: function() { + const dialog = ContentShareMod.unshareDialog; + const sharesListElement = dialog.querySelector('#shares-list'); + + const startIndex = (this.currentPage - 1) * this.sharesPerPage; + const endIndex = startIndex + this.sharesPerPage; + const currentShares = this.sharesList.slice(startIndex, endIndex); + + sharesListElement.innerHTML = currentShares.map(share => ` + + `).join(''); + + sharesListElement.style.display = ''; + + const pagination = dialog.querySelector('.pagination'); + pagination.querySelector('.page-info').textContent = `${this.currentPage}/${this.totalPages}`; + const [prevButton, nextButton] = pagination.querySelectorAll('.page-btn'); + prevButton.disabled = this.currentPage === 1; + nextButton.disabled = this.currentPage === this.totalPages; + }, + + previousPage: function() { + if (this.currentPage > 1) { + this.currentPage--; + this.updatePageContent(); + } + }, + + nextPage: function() { + if (this.currentPage < this.totalPages) { + this.currentPage++; + this.updatePageContent(); + } + }, + + viewUserProfile: function(userId) { + this.hide(); + getUserDetails(userId); + }, + + revokeShare: function(userId, buttonElement) { + const contentDetails = ContentShareMod.currentContentData; + const contentType = contentDetails.AvatarId ? 'Avatar' : 'Spawnable'; + const contentId = contentDetails.AvatarId || contentDetails.SpawnableId; + + buttonElement.disabled = true; + buttonElement.textContent = 'Revoking...'; + + engine.call('NAKRevokeContentShare', contentType, contentId, userId); + }, + + handleRevokeResponse: function(success, userId, error) { + const dialog = ContentShareMod.unshareDialog; + const shareItem = dialog.querySelector(`[data-user-id="${userId}"]`); + if (!shareItem) return; + + const actionButton = shareItem.querySelector('button'); + + if (success) { + shareItem.classList.add('revoked'); + actionButton.className = 'action-btn undo-btn button'; + actionButton.textContent = 'Undo'; + actionButton.onclick = () => { + actionButton.disabled = true; + actionButton.textContent = 'Restoring...'; + + const contentDetails = ContentShareMod.currentContentData; + const contentType = contentDetails.AvatarId ? 'Avatar' : 'Spawnable'; + const contentId = contentDetails.AvatarId || contentDetails.SpawnableId; + + engine.call('NAKCallShareContentDirect', contentType, contentId, userId); + }; + uiPushShow("Share revoked successfully", 3); + } else { + actionButton.textContent = 'Failed'; + actionButton.classList.add('failed'); + uiPushShow(error || "Failed to revoke share", 3); + + // Reset button after a moment + setTimeout(() => { + actionButton.disabled = false; + actionButton.textContent = 'Revoke'; + actionButton.classList.remove('failed'); + }, 1000); + } + }, + + handleShareResponse: function(success, userId, error) { + const dialog = ContentShareMod.unshareDialog; + const shareItem = dialog.querySelector(`[data-user-id="${userId}"]`); + if (!shareItem) return; + + const actionButton = shareItem.querySelector('button'); + + if (success) { + this.requestShares(); + uiPushShow("Share restored successfully", 3); + } else { + actionButton.textContent = 'Failed'; + actionButton.classList.add('failed'); + uiPushShow(error || "Failed to restore share", 3); + + // Reset button after a moment + setTimeout(() => { + actionButton.disabled = false; + actionButton.textContent = 'Undo'; + actionButton.classList.remove('failed'); + }, 1000); + } + } + }, + + DirectShare: { + currentPage: 1, + totalPages: 1, + usersPerPage: 5, + usersList: null, + isInstanceUsers: true, + + initStyles: function() { + return ` + .direct-share-dialog { + width: 800px; + height: 1000px; + transform: translate(-50%, -60%); + display: flex; + flex-direction: column; + } + + .direct-share-dialog .search-container { + margin: 20px 0; + padding: 10px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + } + + .direct-share-dialog .search-input { + width: 100%; + padding: 10px; + border: none; + background: transparent; + color: inherit; + font-size: 1.1em; + } + + .direct-share-dialog .source-indicator { + padding: 10px; + text-align: center; + opacity: 0.8; + background: rgba(0, 0, 0, 0.1); + border-radius: 4px; + margin-bottom: 20px; + } + + .direct-share-dialog .users-container { + flex: 1; + overflow-y: auto; + margin-bottom: 20px; + min-height: 0; + } + + .direct-share-dialog .user-item { + display: flex; + align-items: center; + padding: 15px; + border: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 10px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + min-height: 96px; + } + + .direct-share-dialog .user-item img { + width: 96px; + height: 96px; + margin-right: 15px; + cursor: pointer; + border-radius: 4px; + } + + .direct-share-dialog .user-item .user-name { + flex: 1; + font-size: 1.3em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 15px; + cursor: pointer; + } + + .direct-share-dialog .user-item .action-btn { + width: 140px; + height: 50px; + font-size: 1.2em; + color: white; + background-color: rgba(27, 80, 55, 1); + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + } + + .direct-share-dialog .user-item .action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + background-color: #4a9eff; + } + + .direct-share-dialog .pagination { + border-top: 1px solid rgba(255, 255, 255, 0.1); + padding-top: 20px; + } + + .direct-share-dialog .page-info { + font-size: 1.1em; + opacity: 0.8; + margin-bottom: 15px; + text-align: center; + } + + .direct-share-dialog .page-buttons { + display: flex; + justify-content: center; + gap: 20px; + } + + .direct-share-dialog .user-item .action-btn { + width: 180px; + height: 60px; + font-size: 1.2em; + color: white; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 10px; + padding: 0 20px; + text-align: center; + background-color: rgba(27, 80, 55, 1); + } + + .direct-share-dialog .user-item .action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .direct-share-dialog .user-item .action-btn.shared { + background-color: #4a9eff; + } + + .direct-share-dialog .user-item .action-btn.failed { + background-color: #ff6b6b; + } + `; + }, + + createDialog: function() { + const dialog = document.createElement('div'); + dialog.id = 'content-direct-share-dialog'; + dialog.className = 'content-sharing-base-dialog direct-share-dialog hidden'; + dialog.innerHTML = ` +

Direct Share

+
Close
+ +
+
Loading users...
+ + + +
+ + + `; + document.body.appendChild(dialog); + return dialog; + }, + + show: function(contentDetails) { + const dialog = ContentShareMod.directShareDialog; + ContentShareMod.applyThemeToDialog(dialog); + + dialog.classList.remove('hidden', 'out'); + setTimeout(() => dialog.classList.add('in'), 50); + + ContentShareMod.currentContentData = contentDetails; + this.currentPage = 1; + this.totalPages = 1; + this.usersList = null; + this.requestUsers(true); + }, + + hide: function() { + const dialog = ContentShareMod.directShareDialog; + dialog.classList.remove('in'); + dialog.classList.add('out'); + setTimeout(() => { + dialog.classList.add('hidden'); + dialog.classList.remove('out'); + }, 200); + }, + + handleUsersResponse: function(success, users, isInstanceUsers) { + const dialog = ContentShareMod.directShareDialog; + const usersContainer = dialog.querySelector('.users-container'); + // const sourceIndicator = dialog.querySelector('.source-indicator'); + const loadingElement = usersContainer.querySelector('#users-loading'); + const errorElement = usersContainer.querySelector('#users-error'); + const emptyElement = usersContainer.querySelector('#users-empty'); + const usersListElement = usersContainer.querySelector('#users-list'); + + loadingElement.style.display = 'none'; + // sourceIndicator.textContent = isInstanceUsers ? + // 'Showing users in current instance' : + // 'Showing search results'; + + // TODO: Add source indicator to html: + //
+ // Showing users in current instance + //
+ + if (!success) { + errorElement.style.display = ''; + return; + } + + try { + const response = JSON.parse(users); + this.usersList = response.entries; + this.isInstanceUsers = isInstanceUsers; + + if (!this.usersList || this.usersList.length === 0) { + emptyElement.style.display = ''; + this.updatePagination(); + return; + } + + this.totalPages = Math.ceil(this.usersList.length / this.usersPerPage); + this.updatePageContent(); + } catch (error) { + console.error('Error parsing users:', error); + errorElement.style.display = ''; + } + }, + + handleSearch: function(event) { + if (event.key === 'Enter') { + const searchValue = event.target.value.trim(); + // Pass true for instance users when empty search, false for search results + this.requestUsers(searchValue === '', searchValue); + } + }, + + requestUsers: function(isInstanceUsers, searchQuery = '') { + const dialog = ContentShareMod.directShareDialog; + const usersContainer = dialog.querySelector('.users-container'); + + usersContainer.querySelector('#users-loading').style.display = ''; + usersContainer.querySelector('#users-error').style.display = 'none'; + usersContainer.querySelector('#users-empty').style.display = 'none'; + usersContainer.querySelector('#users-list').style.display = 'none'; + + engine.call('NAKGetUsersForSharing', searchQuery); + }, + + updatePageContent: function() { + const dialog = ContentShareMod.directShareDialog; + const usersListElement = dialog.querySelector('#users-list'); + + const startIndex = (this.currentPage - 1) * this.usersPerPage; + const endIndex = startIndex + this.usersPerPage; + const currentUsers = this.usersList.slice(startIndex, endIndex); + + usersListElement.innerHTML = currentUsers.map(user => ` +
+ ${user.name}'s avatar + ${user.name} + +
+ `).join(''); + + usersListElement.style.display = ''; + this.updatePagination(); + }, + + updatePagination: function() { + const dialog = ContentShareMod.directShareDialog; + const pagination = dialog.querySelector('.pagination'); + const [prevButton, nextButton] = pagination.querySelectorAll('.page-btn'); + + pagination.querySelector('.page-info').textContent = `Page ${this.currentPage}/${this.totalPages}`; + prevButton.disabled = this.currentPage === 1; + nextButton.disabled = this.currentPage === this.totalPages; + }, + + previousPage: function() { + if (this.currentPage > 1) { + this.currentPage--; + this.updatePageContent(); + } + }, + + nextPage: function() { + if (this.currentPage < this.totalPages) { + this.currentPage++; + this.updatePageContent(); + } + }, + + viewUserProfile: function(userId) { + this.hide(); + getUserDetails(userId); + }, + + shareWithUser: function(userId, buttonElement) { + const contentDetails = ContentShareMod.currentContentData; + const contentType = contentDetails.AvatarId ? 'Avatar' : 'Spawnable'; + const contentId = contentDetails.AvatarId || contentDetails.SpawnableId; + const contentName = contentDetails.AvatarName || contentDetails.SpawnableName; + const contentImage = contentDetails.AvatarImageURL || contentDetails.SpawnableImageURL; + + buttonElement.disabled = true; + buttonElement.textContent = 'Sharing...'; + + engine.call('NAKCallShareContentDirect', contentType, contentId, userId, contentName, contentImage); + }, + + handleShareResponse: function(success, userId, error) { + const dialog = ContentShareMod.directShareDialog; + const userItem = dialog.querySelector(`[data-user-id="${userId}"]`); + if (!userItem) return; + + const actionButton = userItem.querySelector('button'); + + if (success) { + actionButton.textContent = 'Shared'; + actionButton.disabled = true; + actionButton.classList.add('shared'); + uiPushShow("Content shared successfully", 3, "shareresponse"); + } else { + actionButton.disabled = false; + actionButton.textContent = 'Failed'; + actionButton.classList.add('failed'); + uiPushShow(error || "Failed to share content", 3, "shareresponse"); + + // Reset button after a moment + setTimeout(() => { + actionButton.disabled = false; + actionButton.textContent = 'Share'; + actionButton.classList.remove('failed', 'shared'); + }, 1000); + } + } + }, + + ShareSelect: { + initStyles: function() { + return ` + .share-select-dialog { + width: 650px; + height: 480px; + transform: translate(-50%, -80%); + } + + .share-select-dialog .share-options { + display: flex; + flex-direction: column; + gap: 15px; + margin-top: 20px; + } + + .share-select-dialog .share-option { + padding: 20px; + text-align: left; + cursor: pointer; + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + transition: background-color 0.2s ease; + } + + .share-select-dialog .share-option:hover { + background-color: rgba(27, 80, 55, 1); + border-color: rgba(255, 255, 255, 0.2); + } + + .share-select-dialog h3 { + margin: 0 0 8px 0; + font-size: 1.2em; + } + + .share-select-dialog p { + margin: 0; + opacity: 0.8; + font-size: 0.95em; + line-height: 1.4; + } + `; + }, + + createDialog: function() { + const dialog = document.createElement('div'); + dialog.id = 'content-share-select-dialog'; + dialog.className = 'content-sharing-base-dialog share-select-dialog hidden'; + dialog.innerHTML = ` +

Share Content

+
Close
+ + + `; + document.body.appendChild(dialog); + return dialog; + }, + + show: function(contentDetails) { + const dialog = ContentShareMod.shareSelectDialog; + ContentShareMod.applyThemeToDialog(dialog); + + dialog.classList.remove('hidden', 'out'); + setTimeout(() => dialog.classList.add('in'), 50); + + ContentShareMod.currentContentData = contentDetails; + }, + + hide: function() { + const dialog = ContentShareMod.shareSelectDialog; + dialog.classList.remove('in'); + dialog.classList.add('out'); + setTimeout(() => { + dialog.classList.add('hidden'); + dialog.classList.remove('out'); + }, 200); + }, + + openShareBubble: function() { + this.hide(); + ContentShareMod.ShareBubble.show(ContentShareMod.currentContentData); + }, + + openDirectShare: function() { + this.hide(); + ContentShareMod.DirectShare.show(ContentShareMod.currentContentData); + } + }, + + // Toolbar initialization and event bindings + initializeToolbars: function() { + const findEmptyButtons = (toolbar) => { + return Array.from(toolbar.querySelectorAll('.toolbar-btn')).filter( + btn => btn.textContent.trim() === "" + ); + }; + + const setupToolbar = (selector) => { + const toolbar = document.querySelector(selector); + if (!toolbar) return; + + const emptyButtons = findEmptyButtons(toolbar); + if (emptyButtons.length >= 2) { + emptyButtons[0].classList.add('content-share-btn'); + emptyButtons[0].textContent = 'Share'; + + emptyButtons[1].classList.add('content-unshare-btn'); + emptyButtons[1].textContent = 'Unshare'; + } + }; + + setupToolbar('#avatar-detail .avatar-toolbar'); + setupToolbar('#prop-detail .avatar-toolbar'); + }, + + bindEvents: function() { + // Avatar events + engine.on("LoadAvatarDetails", (avatarDetails) => { + const shareBtn = document.querySelector('#avatar-detail .content-share-btn'); + const unshareBtn = document.querySelector('#avatar-detail .content-unshare-btn'); + const canShareDirectly = avatarDetails.IsMine; + const canUnshare = avatarDetails.IsMine || avatarDetails.IsSharedWithMe; + + if (shareBtn) { + shareBtn.classList.remove('disabled'); + + if (canShareDirectly) { + shareBtn.onclick = () => ContentShareMod.ShareSelect.show(avatarDetails); + } else { + shareBtn.onclick = () => ContentShareMod.ShareBubble.show(avatarDetails); + } + } + + if (unshareBtn) { + if (canUnshare) { + unshareBtn.classList.remove('disabled'); + unshareBtn.onclick = () => { + if (avatarDetails.IsMine) { + ContentShareMod.Unshare.show(avatarDetails); + } else { + uiConfirmShow("Unshare Avatar", + "Are you sure you want to unshare this avatar?", + "unshare_avatar_confirmation", + avatarDetails.AvatarId); + } + }; + } else { + unshareBtn.classList.add('disabled'); + unshareBtn.onclick = null; + } + } + + ContentShareMod.currentContentData = avatarDetails; + }); + + // Prop events + engine.on("LoadPropDetails", (propDetails) => { + const shareBtn = document.querySelector('#prop-detail .content-share-btn'); + const unshareBtn = document.querySelector('#prop-detail .content-unshare-btn'); + const canShareDirectly = propDetails.IsMine; + const canUnshare = propDetails.IsMine || propDetails.IsSharedWithMe; + + if (shareBtn) { + shareBtn.classList.remove('disabled'); + + if (canShareDirectly) { + shareBtn.onclick = () => ContentShareMod.ShareSelect.show(propDetails); + } else { + shareBtn.onclick = () => ContentShareMod.ShareBubble.show(propDetails); + } + } + + if (unshareBtn) { + if (canUnshare) { + unshareBtn.classList.remove('disabled'); + unshareBtn.onclick = () => { + if (propDetails.IsMine) { + ContentShareMod.Unshare.show(propDetails); + } else { + uiConfirmShow("Unshare Prop", + "Are you sure you want to unshare this prop?", + "unshare_prop_confirmation", + propDetails.SpawnableId); + } + }; + } else { + unshareBtn.classList.add('disabled'); + unshareBtn.onclick = null; + } + } + + ContentShareMod.currentContentData = propDetails; + }); + + // Share response handlers + engine.on("OnHandleSharesResponse", (success, shares) => { + if (ContentShareMod.debugMode) { + console.log('Shares response:', success, shares); + } + ContentShareMod.Unshare.handleSharesResponse(success, shares); + }); + + engine.on("OnHandleRevokeResponse", (success, userId, error) => { + ContentShareMod.Unshare.handleRevokeResponse(success, userId, error); + }); + + engine.on("OnHandleShareResponse", function(success, userId, error) { + // Pass event to Unshare and DirectShare modules depending on which dialog is open + if (ContentShareMod.unshareDialog && !ContentShareMod.unshareDialog.classList.contains('hidden')) { + ContentShareMod.Unshare.handleShareResponse(success, userId, error); + } else if (ContentShareMod.directShareDialog && !ContentShareMod.directShareDialog.classList.contains('hidden')) { + ContentShareMod.DirectShare.handleShareResponse(success, userId, error); + } + }); + + // Share release handlers + engine.on("OnReleasedAvatarShare", (contentId) => { + if (!ContentShareMod.currentContentData || + ContentShareMod.currentContentData.AvatarId !== contentId) return; + + const unshareBtn = document.querySelector('#avatar-detail .content-unshare-btn'); + ContentShareMod.currentContentData.IsSharedWithMe = false; + + if (unshareBtn) { + unshareBtn.classList.add('disabled'); + unshareBtn.onclick = null; + } + + const contentIsAccessible = ContentShareMod.currentContentData.IsMine || + ContentShareMod.currentContentData.IsPublic; + + if (!contentIsAccessible) { + const detail = document.querySelector('#avatar-detail'); + if (detail) { + ['drop-btn', 'select-btn', 'fav-btn'].forEach(className => { + const button = detail.querySelector('.' + className); + if (button) { + button.classList.add('disabled'); + button.removeAttribute('onclick'); + } + }); + } + } + }); + + engine.on("OnReleasedPropShare", (contentId) => { + if (!ContentShareMod.currentContentData || + ContentShareMod.currentContentData.SpawnableId !== contentId) return; + + const unshareBtn = document.querySelector('#prop-detail .content-unshare-btn'); + ContentShareMod.currentContentData.IsSharedWithMe = false; + + if (unshareBtn) { + unshareBtn.classList.add('disabled'); + unshareBtn.onclick = null; + } + + const contentIsAccessible = ContentShareMod.currentContentData.IsMine || + ContentShareMod.currentContentData.IsPublic; + + if (!contentIsAccessible) { + const detail = document.querySelector('#prop-detail'); + if (detail) { + ['drop-btn', 'select-btn', 'fav-btn'].forEach(className => { + const button = detail.querySelector('.' + className); + if (button) { + button.classList.add('disabled'); + button.removeAttribute('onclick'); + } + }); + } + } + }); + + engine.on("OnHandleUsersResponse", (success, users, isInstanceUsers) => { + if (ContentShareMod.debugMode) { + console.log('Users response:', success, isInstanceUsers, users); + } + ContentShareMod.DirectShare.handleUsersResponse(success, users, isInstanceUsers); + }); + } +}; + +ContentShareMod.init(); + +"""; + + private const string UiConfirmId_ReleaseAvatarShareWarning = "unshare_avatar_confirmation"; + private const string UiConfirmId_ReleasePropShareWarning = "unshare_prop_confirmation"; + + [HarmonyPostfix] + [HarmonyPatch(typeof(ViewManager), nameof(ViewManager.Start))] + public static void Postfix_ViewManager_Start(ViewManager __instance) + { + // Inject the details toolbar patches when the game menu view is loaded + __instance.gameMenuView.Listener.FinishLoad += _ => { + __instance.gameMenuView.View._view.ExecuteScript(DETAILS_TOOLBAR_PATCHES); + __instance.gameMenuView.View.BindCall("NAKCallShareContent", OnShareContent); + __instance.gameMenuView.View.BindCall("NAKGetContentShares", OnGetContentShares); + __instance.gameMenuView.View.BindCall("NAKRevokeContentShare", OnRevokeContentShare); + __instance.gameMenuView.View.BindCall("NAKCallShareContentDirect", OnShareContentDirect); + __instance.gameMenuView.View.BindCall("NAKGetUsersForSharing", OnGetUsersForSharing); + }; + + // Add the event listener for the unshare confirmation dialog + __instance.OnUiConfirm.AddListener(OnReleaseContentShareConfirmation); + + return; + + void OnShareContent( + string action, + string bubbleImpl, + string bubbleContent, + string shareRule, + string shareLifetime, + string shareAccess, + string contentImage, + string contentName) + { + // Action: drop, select + // BubbleImpl: Avatar, Prop, World, User + // BubbleContent: AvatarId, PropId, WorldId, UserId + // ShareRule: Public, FriendsOnly + // ShareLifetime: TwoMinutes, Session + // ShareAccess: PermanentAccess, SessionAccess, NoAccess + + ShareRule rule = shareRule switch + { + "Everyone" => ShareRule.Everyone, + "FriendsOnly" => ShareRule.FriendsOnly, + _ => ShareRule.Everyone + }; + + ShareLifetime lifetime = shareLifetime switch + { + "Session" => ShareLifetime.Session, + "TwoMinutes" => ShareLifetime.TwoMinutes, + _ => ShareLifetime.TwoMinutes + }; + + ShareAccess access = shareAccess switch + { + "Permanent" => ShareAccess.Permanent, + "Session" => ShareAccess.Session, + "None" => ShareAccess.None, + _ => ShareAccess.None + }; + + uint implTypeHash = ShareBubbleManager.GetMaskedHash(bubbleImpl); + ShareBubbleData bubbleData = new() + { + BubbleId = ShareBubbleManager.GenerateBubbleId(bubbleContent, implTypeHash), + ImplTypeHash = implTypeHash, + ContentId = bubbleContent, + Rule = rule, + Lifetime = lifetime, + Access = access, + CreatedAt = DateTime.UtcNow + }; + + switch (action) + { + case "drop": + ShareBubbleManager.Instance.DropBubbleInFront(bubbleData); + break; + case "select": + ShareBubbleManager.Instance.SelectBubbleForPlace(contentImage, contentName, bubbleData); + break; + } + + // Close menu + ViewManager.Instance.UiStateToggle(false); + } + + void OnReleaseContentShareConfirmation(string id, string value, string contentId) + { + // Check if the confirmation event is for unsharing content + if (id != UiConfirmId_ReleaseAvatarShareWarning + && id != UiConfirmId_ReleasePropShareWarning) + return; + + //ShareBubblesMod.Logger.Msg($"Unshare confirmation received: {id}, {value}"); + + // Check if the user confirmed the unshare action + if (value != "true") + { + //ShareBubblesMod.Logger.Msg("Unshare action cancelled by user"); + return; + } + + //ShareBubblesMod.Logger.Msg("Releasing share..."); + + // Determine the content type based on the confirmation ID + ShareApiHelper.ShareContentType contentType = id == UiConfirmId_ReleaseAvatarShareWarning + ? ShareApiHelper.ShareContentType.Avatar + : ShareApiHelper.ShareContentType.Spawnable; + + Task.Run(async () => { + try + { + await ShareApiHelper.ReleaseShareAsync(contentType, contentId); + MTJobManager.RunOnMainThread("release_share_response", () => + { + // Cannot display a success message as opening details page pushes itself to top + // after talking to api, so success message would need to be timed to show after + // if (contentType == ApiShareHelper.ShareContentType.Avatar) + // ViewManager.Instance.RequestAvatarDetailsPage(contentId); + // else + // ViewManager.Instance.GetPropDetails(contentId); + + ViewManager.Instance.gameMenuView.View._view.TriggerEvent( + contentType == ShareApiHelper.ShareContentType.Avatar + ? "OnReleasedAvatarShare" : "OnReleasedPropShare", + contentId); + + ViewManager.Instance.TriggerPushNotification("Content unshared successfully", 3f); + }); + } + catch (ShareApiException ex) + { + ShareBubblesMod.Logger.Error($"Share API error: {ex.Message}"); + MTJobManager.RunOnMainThread("release_share_error", () => { + ViewManager.Instance.TriggerAlert("Release Share Error", ex.UserFriendlyMessage, -1, true); + }); + } + catch (Exception ex) + { + ShareBubblesMod.Logger.Error($"Unexpected error releasing share: {ex.Message}"); + MTJobManager.RunOnMainThread("release_share_error", () => { + ViewManager.Instance.TriggerAlert("Release Share Error", "An unexpected error occurred", -1, true); + }); + } + }); + } + + async void OnGetContentShares(string contentType, string contentId) + { + try + { + var response = await ShareApiHelper.GetSharesAsync>( + contentType == "Avatar" ? ShareApiHelper.ShareContentType.Avatar : ShareApiHelper.ShareContentType.Spawnable, + contentId + ); + + // TODO: somethign better than this cause this is ass and i need to replace the image urls with ImageCache coui ones + // FUICJK< + string json = JsonConvert.SerializeObject(response.Data); + + // log the json to console + //ShareBubblesMod.Logger.Msg($"Shares response: {json}"); + + __instance.gameMenuView.View.TriggerEvent("OnHandleSharesResponse", true, json); + } + catch (Exception ex) + { + ShareBubblesMod.Logger.Error($"Failed to get content shares: {ex.Message}"); + __instance.gameMenuView.View.TriggerEvent("OnHandleSharesResponse", false); + } + } + + async void OnRevokeContentShare(string contentType, string contentId, string userId) + { + try + { + await ShareApiHelper.ReleaseShareAsync( + contentType == "Avatar" ? ShareApiHelper.ShareContentType.Avatar : ShareApiHelper.ShareContentType.Spawnable, + contentId, + userId + ); + + __instance.gameMenuView.View.TriggerEvent("OnHandleRevokeResponse", true, userId); + } + catch (ShareApiException ex) + { + ShareBubblesMod.Logger.Error($"Share API error revoking share: {ex.Message}"); + __instance.gameMenuView.View.TriggerEvent("OnHandleRevokeResponse", false, userId, ex.UserFriendlyMessage); + } + catch (Exception ex) + { + ShareBubblesMod.Logger.Error($"Unexpected error revoking share: {ex.Message}"); + __instance.gameMenuView.View.TriggerEvent("OnHandleRevokeResponse", false, userId, "An unexpected error occurred"); + } + } + + async void OnShareContentDirect(string contentType, string contentId, string userId, string contentName = "", string contentImage = "") + { + try + { + ShareApiHelper.ShareContentType shareContentType = contentType == "Avatar" + ? ShareApiHelper.ShareContentType.Avatar + : ShareApiHelper.ShareContentType.Spawnable; + + await ShareApiHelper.ShareContentAsync( + shareContentType, + contentId, + userId + ); + + // Alert the user that the share occurred + //ModNetwork.SendDirectShareNotification(userId, shareContentType, contentId); + + __instance.gameMenuView.View._view.TriggerEvent("OnHandleShareResponse", true, userId); + } + catch (ShareApiException ex) + { + ShareBubblesMod.Logger.Error($"Share API error: {ex.Message}"); + __instance.gameMenuView.View._view.TriggerEvent("OnHandleShareResponse", false, userId, ex.UserFriendlyMessage); + } + catch (Exception ex) + { + ShareBubblesMod.Logger.Error($"Unexpected error sharing content: {ex.Message}"); + __instance.gameMenuView.View._view.TriggerEvent("OnHandleShareResponse", false, userId, "An unexpected error occurred"); + } + } + void OnGetUsersForSharing(string searchTerm = "") + { + try + { + if (!string.IsNullOrEmpty(searchTerm)) + { + // TODO: Search users implementation will go here + // For now just return an empty list + var response = new { entries = new List() }; + string json = JsonConvert.SerializeObject(response); + __instance.gameMenuView.View.TriggerEvent("OnHandleUsersResponse", true, json, false); + } + else + { + // Get instance users + CVRPlayerManager playerManager = CVRPlayerManager.Instance; + if (playerManager == null) + { + __instance.gameMenuView.View.TriggerEvent("OnHandleUsersResponse", false, false, true); + return; + } + + var response = new + { + entries = playerManager.NetworkPlayers + .Where(p => p != null && !string.IsNullOrEmpty(p.Uuid) + && !MetaPort.Instance.blockedUserIds.Contains(p.Uuid)) // You SHOULDNT HAVE TO DO THIS, but GS dumb + .Select(p => new + { + id = p.Uuid, + name = p.Username, + image = p.ApiProfileImageUrl + }) + .ToList() + }; + + string json = JsonConvert.SerializeObject(response); + __instance.gameMenuView.View.TriggerEvent("OnHandleUsersResponse", true, json, true); + } + } + catch (Exception ex) + { + ShareBubblesMod.Logger.Error($"Failed to get users: {ex.Message}"); + __instance.gameMenuView.View.TriggerEvent("OnHandleUsersResponse", false, false, string.IsNullOrEmpty(searchTerm)); + } + } + } +} \ No newline at end of file diff --git a/ShareBubbles/Properties/AssemblyInfo.cs b/ShareBubbles/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..06006a8 --- /dev/null +++ b/ShareBubbles/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +using MelonLoader; +using System.Reflection; +using NAK.ShareBubbles.Properties; + +[assembly: AssemblyVersion(AssemblyInfoParams.Version)] +[assembly: AssemblyFileVersion(AssemblyInfoParams.Version)] +[assembly: AssemblyInformationalVersion(AssemblyInfoParams.Version)] +[assembly: AssemblyTitle(nameof(NAK.ShareBubbles))] +[assembly: AssemblyCompany(AssemblyInfoParams.Author)] +[assembly: AssemblyProduct(nameof(NAK.ShareBubbles))] + +[assembly: MelonInfo( + typeof(NAK.ShareBubbles.ShareBubblesMod), + nameof(NAK.ShareBubbles), + AssemblyInfoParams.Version, + AssemblyInfoParams.Author, + downloadLink: "https://github.com/NotAKidoS/NAK_CVR_Mods/tree/main/ShareBubbles" +)] + +[assembly: MelonGame("Alpha Blend Interactive", "ChilloutVR")] +[assembly: MelonPlatform(MelonPlatformAttribute.CompatiblePlatforms.WINDOWS_X64)] +[assembly: MelonPlatformDomain(MelonPlatformDomainAttribute.CompatibleDomains.MONO)] +[assembly: MelonColor(255, 246, 25, 99)] // red-pink +[assembly: MelonAuthorColor(255, 158, 21, 32)] // red +[assembly: HarmonyDontPatchAll] + +namespace NAK.ShareBubbles.Properties; +internal static class AssemblyInfoParams +{ + public const string Version = "1.0.2"; + public const string Author = "NotAKidoS, Exterrata, Noachi, RaidShadowLily, Tejler, Luc"; +} \ No newline at end of file diff --git a/ShareBubbles/README.md b/ShareBubbles/README.md new file mode 100644 index 0000000..27e11cf --- /dev/null +++ b/ShareBubbles/README.md @@ -0,0 +1,43 @@ +# Share Bubbles + +Share Bubbles! Allows you to drop down bubbles containing Avatars & Props. Requires both users to have the mod installed. Synced over Mod Network. + +### How to use +Open a Content Details page and click the Share button. You can choose between placing a Share Bubble or Sharing directly with users in the same instance. When placing a Share Bubble, you will be prompted to configure the bubble. Once you're ready, you can then Drop or Select the bubble for placement. + +While viewing content details of an item shared *to you* by another player, you can also click **Unshare** to revoke access to the content. + +#### Visibility +- **Everyone** - All players with the mod installed can see the bubble. +- **Friends Only** - Only friends with the mod installed can see the bubble. + +#### Lifetime +- **2 Minutes** - The bubble will disappear after 2 minutes. +- **Session** - The bubble will only disappear once you delete it with Delete mode or leave the instance. + +#### Access Control +- **Keep Access** - Users who claim the bubble get to keep the shared content. +- **Session Access** - Users who claim the bubble only get to keep the shared content for however long they are in the instance with you. +- **No Access** - Users will not be able to claim the contents of the bubble and will only be able to view it. + +Access Control is only available for **Private Content** and serves as a way to share content in-game. + +**Note:** Session Access requires the game to be running to revoke access once you or the claimant leaves the instance. If the game is closed unexpectedly, the claimant will keep the content until you next launch the game and connect to an online instance. + +## Credits +- Noachi - the bubble +- RaidShadowLily - the particles +- Tejler - the bell sound +- Luc - the fixing pedestal api endpoint +- Exterrata - the loading hexagon model from the hit mod PropLoadingHexagon + +--- + +Here is the block of text where I tell you this mod is not affiliated with or endorsed by ABI. +https://documentation.abinteractive.net/official/legal/tos/#7-modding-our-games + +> This mod is an independent creation not affiliated with, supported by, or approved by Alpha Blend Interactive. + +> Use of this mod is done so at the user's own risk and the creator cannot be held responsible for any issues arising from its use. + +> To the best of my knowledge, I have adhered to the Modding Guidelines established by Alpha Blend Interactive. diff --git a/ShareBubbles/Resources/sharingbubble.assets b/ShareBubbles/Resources/sharingbubble.assets new file mode 100644 index 0000000..339e629 Binary files /dev/null and b/ShareBubbles/Resources/sharingbubble.assets differ diff --git a/ShareBubbles/ShareBubbles.csproj b/ShareBubbles/ShareBubbles.csproj new file mode 100644 index 0000000..734c1ec --- /dev/null +++ b/ShareBubbles/ShareBubbles.csproj @@ -0,0 +1,33 @@ + + + + net48 + + + TRACE;TRACE;UNITY_2017_1_OR_NEWER;UNITY_2018_1_OR_NEWER;UNITY_2019_3_OR_NEWER;USE_BURST;USE_NEWMATHS;USE_BURST_REALLY;USE_TERRAINS + + + + + + + + + + + + + + + + + + ..\.ManagedLibs\BTKUILib.dll + + + + + + + + diff --git a/ShareBubbles/ShareBubbles/API/Exceptions/ShareApiExceptions.cs b/ShareBubbles/ShareBubbles/API/Exceptions/ShareApiExceptions.cs new file mode 100644 index 0000000..799a6a2 --- /dev/null +++ b/ShareBubbles/ShareBubbles/API/Exceptions/ShareApiExceptions.cs @@ -0,0 +1,66 @@ +using System.Net; + +namespace NAK.ShareBubbles.API.Exceptions; + +public class ShareApiException : Exception +{ + public HttpStatusCode StatusCode { get; } // TODO: network back status code to claiming client, to show why the request failed + public string UserFriendlyMessage { get; } + + public ShareApiException(HttpStatusCode statusCode, string message, string userFriendlyMessage) + : base(message) + { + StatusCode = statusCode; + UserFriendlyMessage = userFriendlyMessage; + } +} + +public class ContentNotSharedException : ShareApiException +{ + public ContentNotSharedException(string contentId) + : base(HttpStatusCode.BadRequest, + $"Content {contentId} is not currently shared", + "This content is not currently shared with anyone") + { + } +} + +public class ContentNotFoundException : ShareApiException +{ + public ContentNotFoundException(string contentId) + : base(HttpStatusCode.NotFound, + $"Content {contentId} not found", + "The specified content could not be found") + { + } +} + +public class UserOnlyAllowsSharesFromFriendsException : ShareApiException +{ + public UserOnlyAllowsSharesFromFriendsException(string userId) + : base(HttpStatusCode.Forbidden, + $"User {userId} only accepts shares from friends", + "This user only accepts shares from friends") + { + } +} + +public class UserNotFoundException : ShareApiException +{ + public UserNotFoundException(string userId) + : base(HttpStatusCode.NotFound, + $"User {userId} not found", + "The specified user could not be found") + { + } +} + +public class ContentAlreadySharedException : ShareApiException +{ + public ContentAlreadySharedException(string contentId, string userId) + : base(HttpStatusCode.Conflict, + $"Content {contentId} is already shared with user {userId}", + "This content is already shared with this user") + { + } +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/API/PedestalInfoBatchProcessor.cs b/ShareBubbles/ShareBubbles/API/PedestalInfoBatchProcessor.cs new file mode 100644 index 0000000..280a32f --- /dev/null +++ b/ShareBubbles/ShareBubbles/API/PedestalInfoBatchProcessor.cs @@ -0,0 +1,112 @@ +using ABI_RC.Core.Networking.API; +using NAK.ShareBubbles.API.Responses; + +namespace NAK.ShareBubbles.API; + +public enum PedestalType +{ + Avatar, + Prop +} + +/// +/// Batch processor for fetching pedestal info in bulk. The pedestal endpoints are meant to be used in batch, so might +/// as well handle bubble requests in batches if in the very unlikely case that multiple requests are made at once. +/// +/// May only really matter for when a late joiner joins a room with a lot of bubbles. +/// +/// Luc: if there is a lot being dropped, you could try to batch them +/// +public static class PedestalInfoBatchProcessor +{ + private static readonly Dictionary>> _pendingRequests + = new() + { + { PedestalType.Avatar, new Dictionary>() }, + { PedestalType.Prop, new Dictionary>() } + }; + + private static readonly Dictionary _isBatchProcessing + = new() + { + { PedestalType.Avatar, false }, + { PedestalType.Prop, false } + }; + + private static readonly object _lock = new(); + private const float BATCH_DELAY = 2f; + + public static Task QueuePedestalInfoRequest(PedestalType type, string contentId) + { + var tcs = new TaskCompletionSource(); + + lock (_lock) + { + var requests = _pendingRequests[type]; + + if (!requests.TryAdd(contentId, tcs)) + return requests[contentId].Task; + + if (_isBatchProcessing[type]) + return tcs.Task; + + _isBatchProcessing[type] = true; + ProcessBatchAfterDelay(type); + } + + return tcs.Task; + } + + private static async void ProcessBatchAfterDelay(PedestalType type) + { + await Task.Delay(TimeSpan.FromSeconds(BATCH_DELAY)); + + List contentIds; + Dictionary> requestBatch; + + lock (_lock) + { + contentIds = _pendingRequests[type].Keys.ToList(); + requestBatch = new Dictionary>(_pendingRequests[type]); + _pendingRequests[type].Clear(); + _isBatchProcessing[type] = false; + //ShareBubblesMod.Logger.Msg($"Processing {type} pedestal info batch with {contentIds.Count} items"); + } + + try + { + ApiConnection.ApiOperation operation = type switch + { + PedestalType.Avatar => ApiConnection.ApiOperation.AvatarPedestal, + PedestalType.Prop => ApiConnection.ApiOperation.PropPedestal, + _ => throw new ArgumentException($"Unsupported pedestal type: {type}") + }; + + var response = await ApiConnection.MakeRequest>(operation, contentIds); + + if (response?.Data != null) + { + var responseDict = response.Data.ToDictionary(info => info.Id); + + foreach (var kvp in requestBatch) + { + if (responseDict.TryGetValue(kvp.Key, out var info)) + kvp.Value.SetResult(info); + else + kvp.Value.SetException(new Exception($"Content info not found for ID: {kvp.Key}")); + } + } + else + { + Exception exception = new($"Failed to fetch {type} info batch"); + foreach (var tcs in requestBatch.Values) + tcs.SetException(exception); + } + } + catch (Exception ex) + { + foreach (var tcs in requestBatch.Values) + tcs.SetException(ex); + } + } +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/API/Responses/ActiveSharesResponse.cs b/ShareBubbles/ShareBubbles/API/Responses/ActiveSharesResponse.cs new file mode 100644 index 0000000..70dc731 --- /dev/null +++ b/ShareBubbles/ShareBubbles/API/Responses/ActiveSharesResponse.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace NAK.ShareBubbles.API.Responses; + +public class ActiveSharesResponse +{ + [JsonProperty("value")] + public List Value { get; set; } +} + +// Idk why not just reuse UserDetails +public class ShareUser +{ + [JsonProperty("image")] + public string Image { get; set; } + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/API/Responses/PedestalInfoResponseButWithPublicationState.cs b/ShareBubbles/ShareBubbles/API/Responses/PedestalInfoResponseButWithPublicationState.cs new file mode 100644 index 0000000..ac764cb --- /dev/null +++ b/ShareBubbles/ShareBubbles/API/Responses/PedestalInfoResponseButWithPublicationState.cs @@ -0,0 +1,13 @@ +using ABI_RC.Core.Networking.API.Responses; + +namespace NAK.ShareBubbles.API.Responses; + +/// Same as PedestalInfoResponse, but with an additional field for publication state, if you could not tell by the name. +/// TODO: actually waiting on luc to add Published to PedestalInfoResponse +[Serializable] +public class PedestalInfoResponseButWithPublicationState : UgcResponse +{ + public UserDetails User { get; set; } + public bool Permitted { get; set; } + public bool Published { get; set; } +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/API/ShareApiHelper.cs b/ShareBubbles/ShareBubbles/API/ShareApiHelper.cs new file mode 100644 index 0000000..3200deb --- /dev/null +++ b/ShareBubbles/ShareBubbles/API/ShareApiHelper.cs @@ -0,0 +1,236 @@ +using System.Net; +using System.Text; +using System.Web; +using ABI_RC.Core.Networking; +using ABI_RC.Core.Networking.API; +using ABI_RC.Core.Networking.API.Responses; +using ABI_RC.Core.Savior; +using NAK.ShareBubbles.API.Exceptions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace NAK.ShareBubbles.API; + +/// +/// API for content sharing management. +/// +public static class ShareApiHelper +{ + #region Enums + + public enum ShareContentType + { + Avatar, + Spawnable + } + + private enum ShareApiOperation + { + ShareAvatar, + ReleaseAvatar, + + ShareSpawnable, + ReleaseSpawnable, + + GetAvatarShares, + GetSpawnableShares + } + + #endregion Enums + + #region Public API + + /// + /// Shares content with a specified user. + /// + /// Type of content to share + /// ID of the content + /// Target user ID + /// Response containing share information + /// Thrown when API request fails + /// Thrown when target user is not found + /// Thrown when content is not found + /// Thrown when user only accepts shares from friends + /// Thrown when content is already shared with user + public static Task> ShareContentAsync(ShareContentType type, string contentId, string userId) + { + ShareApiOperation operation = type == ShareContentType.Avatar + ? ShareApiOperation.ShareAvatar + : ShareApiOperation.ShareSpawnable; + + ShareRequest data = new() + { + ContentId = contentId, + UserId = userId + }; + + return MakeApiRequestAsync(operation, data); + } + + /// + /// Releases shared content from a specified user. + /// + /// Type of content to release + /// ID of the content + /// Optional user ID. If null, releases share from self + /// Response indicating success + /// Thrown when API request fails + /// Thrown when content is not shared + /// Thrown when content is not found + /// Thrown when specified user is not found + public static Task> ReleaseShareAsync(ShareContentType type, string contentId, string userId = null) + { + ShareApiOperation operation = type == ShareContentType.Avatar + ? ShareApiOperation.ReleaseAvatar + : ShareApiOperation.ReleaseSpawnable; + + // If no user ID is provided, release share from self + userId ??= MetaPort.Instance.ownerId; + + ShareRequest data = new() + { + ContentId = contentId, + UserId = userId + }; + + return MakeApiRequestAsync(operation, data); + } + + /// + /// Gets all shares for specified content. + /// + /// Type of content + /// ID of the content + /// Response containing share information + /// Thrown when API request fails + /// Thrown when content is not found + public static Task> GetSharesAsync(ShareContentType type, string contentId) + { + ShareApiOperation operation = type == ShareContentType.Avatar + ? ShareApiOperation.GetAvatarShares + : ShareApiOperation.GetSpawnableShares; + + ShareRequest data = new() { ContentId = contentId }; + return MakeApiRequestAsync(operation, data); + } + + #endregion Public API + + #region Private Implementation + + [Serializable] + private record ShareRequest + { + public string ContentId { get; set; } + public string UserId { get; set; } + } + + private static async Task> MakeApiRequestAsync(ShareApiOperation operation, ShareRequest data) + { + ValidateAuthenticationState(); + + (string endpoint, HttpMethod method) = GetApiEndpointAndMethod(operation, data); + using HttpRequestMessage request = CreateHttpRequest(endpoint, method, data); + + try + { + using HttpResponseMessage response = await ApiConnection._client.SendAsync(request); + string content = await response.Content.ReadAsStringAsync(); + + return HandleApiResponse(response, content, data.ContentId, data.UserId); + } + catch (HttpRequestException ex) + { + throw new ShareApiException( + HttpStatusCode.ServiceUnavailable, + $"Failed to communicate with the server: {ex.Message}", + "Unable to connect to the server. Please check your internet connection."); + } + catch (JsonException ex) + { + throw new ShareApiException( + HttpStatusCode.UnprocessableEntity, + $"Failed to process response data: {ex.Message}", + "Server returned invalid data. Please try again later."); + } + } + + private static void ValidateAuthenticationState() + { + if (!AuthManager.IsAuthenticated) + { + throw new ShareApiException( + HttpStatusCode.Unauthorized, + "User is not authenticated", + "Please log in to perform this action"); + } + } + + private static HttpRequestMessage CreateHttpRequest(string endpoint, HttpMethod method, ShareRequest data) + { + HttpRequestMessage request = new(method, endpoint); + + if (method == HttpMethod.Post) + { + JObject json = JObject.FromObject(data); + request.Content = new StringContent(json.ToString(), Encoding.UTF8, "application/json"); + } + + return request; + } + + private static BaseResponse HandleApiResponse(HttpResponseMessage response, string content, string contentId, string userId) + { + if (response.IsSuccessStatusCode) + return CreateSuccessResponse(content); + + // Let specific exceptions propagate up to the caller + throw response.StatusCode switch + { + HttpStatusCode.BadRequest => new ContentNotSharedException(contentId), + HttpStatusCode.NotFound when userId != null => new UserNotFoundException(userId), + HttpStatusCode.NotFound => new ContentNotFoundException(contentId), + HttpStatusCode.Forbidden => new UserOnlyAllowsSharesFromFriendsException(userId), + HttpStatusCode.Conflict => new ContentAlreadySharedException(contentId, userId), + _ => new ShareApiException( + response.StatusCode, + $"API request failed with status {response.StatusCode}: {content}", + "An unexpected error occurred. Please try again later.") + }; + } + + private static BaseResponse CreateSuccessResponse(string content) + { + var response = new BaseResponse("") + { + IsSuccessStatusCode = true, + HttpStatusCode = HttpStatusCode.OK + }; + + if (!string.IsNullOrEmpty(content)) + { + response.Data = JsonConvert.DeserializeObject(content); + } + + return response; + } + + private static (string endpoint, HttpMethod method) GetApiEndpointAndMethod(ShareApiOperation operation, ShareRequest data) + { + string baseUrl = $"{ApiConnection.APIAddress}/{ApiConnection.APIVersion}"; + string encodedContentId = HttpUtility.UrlEncode(data.ContentId); + + return operation switch + { + ShareApiOperation.GetAvatarShares => ($"{baseUrl}/avatars/{encodedContentId}/shares", HttpMethod.Get), + ShareApiOperation.ShareAvatar => ($"{baseUrl}/avatars/{encodedContentId}/shares/{HttpUtility.UrlEncode(data.UserId)}", HttpMethod.Post), + ShareApiOperation.ReleaseAvatar => ($"{baseUrl}/avatars/{encodedContentId}/shares/{HttpUtility.UrlEncode(data.UserId)}", HttpMethod.Delete), + ShareApiOperation.GetSpawnableShares => ($"{baseUrl}/spawnables/{encodedContentId}/shares", HttpMethod.Get), + ShareApiOperation.ShareSpawnable => ($"{baseUrl}/spawnables/{encodedContentId}/shares/{HttpUtility.UrlEncode(data.UserId)}", HttpMethod.Post), + ShareApiOperation.ReleaseSpawnable => ($"{baseUrl}/spawnables/{encodedContentId}/shares/{HttpUtility.UrlEncode(data.UserId)}", HttpMethod.Delete), + _ => throw new ArgumentException($"Unknown operation: {operation}") + }; + } + + #endregion Private Implementation +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/DataTypes/BubblePedestalInfo.cs b/ShareBubbles/ShareBubbles/DataTypes/BubblePedestalInfo.cs new file mode 100644 index 0000000..932fa6c --- /dev/null +++ b/ShareBubbles/ShareBubbles/DataTypes/BubblePedestalInfo.cs @@ -0,0 +1,10 @@ +namespace NAK.ShareBubbles; + +public class BubblePedestalInfo +{ + public string Name; + public string ImageUrl; + public string AuthorId; + public bool IsPermitted; // TODO: awaiting luc to fix not being true for private shared (only true for public) + public bool IsPublic; // TODO: awaiting luc to add +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/DataTypes/ShareBubbleData.cs b/ShareBubbles/ShareBubbles/DataTypes/ShareBubbleData.cs new file mode 100644 index 0000000..2650773 --- /dev/null +++ b/ShareBubbles/ShareBubbles/DataTypes/ShareBubbleData.cs @@ -0,0 +1,80 @@ +using ABI_RC.Systems.ModNetwork; + +namespace NAK.ShareBubbles; + +/// +/// Used to create a bubble. Need to define locally & over-network. +/// +public struct ShareBubbleData +{ + public uint BubbleId; // Local ID of the bubble (ContentId & ImplTypeHash), only unique per user + public uint ImplTypeHash; // Hash of the implementation type (for lookup in ShareBubbleRegistry) + + public string ContentId; // ID of the content being shared + + public ShareRule Rule; // Rule for sharing the bubble + public ShareLifetime Lifetime; // Lifetime of the bubble + public ShareAccess Access; // Access given if requesting bubble content share + public DateTime CreatedAt; // Time the bubble was created for checking lifetime + + public static void AddConverterForModNetwork() + { + ModNetworkMessage.AddConverter(Read, Write); + return; + + ShareBubbleData Read(ModNetworkMessage msg) + { + msg.Read(out uint bubbleId); + msg.Read(out uint implTypeHash); + msg.Read(out string contentId); + + // Pack rule, lifetime, and access into a single byte to save bandwidth + msg.Read(out byte packedFlags); + ShareRule rule = (ShareRule)(packedFlags & 0x0F); // First 4 bits for Rule + ShareLifetime lifetime = (ShareLifetime)((packedFlags >> 4) & 0x3); // Next 2 bits for Lifetime + ShareAccess access = (ShareAccess)((packedFlags >> 6) & 0x3); // Last 2 bits for Access + + // Read timestamp as uint (seconds since epoch) to save space compared to DateTime + msg.Read(out uint timestamp); + DateTime createdAt = DateTime.UnixEpoch.AddSeconds(timestamp); + //ShareBubblesMod.Logger.Msg($"Reading bubble - Seconds from epoch: {timestamp}"); + //ShareBubblesMod.Logger.Msg($"Converted back to time: {createdAt}"); + + // We do not support time traveling bubbles + DateTime now = DateTime.UtcNow; + if (createdAt > now) + createdAt = now; + + return new ShareBubbleData + { + BubbleId = bubbleId, + ImplTypeHash = implTypeHash, + ContentId = contentId, + Rule = rule, + Lifetime = lifetime, + Access = access, + CreatedAt = createdAt + }; + } + + void Write(ModNetworkMessage msg, ShareBubbleData data) + { + msg.Write(data.BubbleId); + msg.Write(data.ImplTypeHash); + msg.Write(data.ContentId); + + // Pack flags into a single byte + byte packedFlags = (byte)( + ((byte)data.Rule & 0x0F) | // First 4 bits for Rule + (((byte)data.Lifetime & 0x3) << 4) | // Next 2 bits for Lifetime + (((byte)data.Access & 0x3) << 6) // Last 2 bits for Access + ); + msg.Write(packedFlags); + + // Write timestamp as uint seconds since epoch + uint timestamp = (uint)(data.CreatedAt.ToUniversalTime() - DateTime.UnixEpoch).TotalSeconds; + //ShareBubblesMod.Logger.Msg($"Writing bubble - Original time: {data.CreatedAt}, Converted to seconds: {timestamp}"); + msg.Write(timestamp); + } + } +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/Enums/ShareAccess.cs b/ShareBubbles/ShareBubbles/Enums/ShareAccess.cs new file mode 100644 index 0000000..af6b417 --- /dev/null +++ b/ShareBubbles/ShareBubbles/Enums/ShareAccess.cs @@ -0,0 +1,8 @@ +namespace NAK.ShareBubbles; + +public enum ShareAccess +{ + Permanent, + Session, + None +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/Enums/ShareLifetime.cs b/ShareBubbles/ShareBubbles/Enums/ShareLifetime.cs new file mode 100644 index 0000000..64962aa --- /dev/null +++ b/ShareBubbles/ShareBubbles/Enums/ShareLifetime.cs @@ -0,0 +1,7 @@ +namespace NAK.ShareBubbles; + +public enum ShareLifetime +{ + Session, + TwoMinutes, +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/Enums/ShareRule.cs b/ShareBubbles/ShareBubbles/Enums/ShareRule.cs new file mode 100644 index 0000000..192aab5 --- /dev/null +++ b/ShareBubbles/ShareBubbles/Enums/ShareRule.cs @@ -0,0 +1,7 @@ +namespace NAK.ShareBubbles; + +public enum ShareRule +{ + Everyone, + FriendsOnly +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/Implementation/AvatarBubbleImpl.cs b/ShareBubbles/ShareBubbles/Implementation/AvatarBubbleImpl.cs new file mode 100644 index 0000000..29c9a14 --- /dev/null +++ b/ShareBubbles/ShareBubbles/Implementation/AvatarBubbleImpl.cs @@ -0,0 +1,103 @@ +using ABI_RC.Core.EventSystem; +using ABI_RC.Core.InteractionSystem; +using ABI_RC.Core.IO; +using ABI_RC.Core.Networking.API.UserWebsocket; +using NAK.ShareBubbles.API; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace NAK.ShareBubbles.Impl +{ + public class AvatarBubbleImpl : IShareBubbleImpl + { + private ShareBubble bubble; + private string avatarId; + private BubblePedestalInfo details; + private Texture2D downloadedTexture; + + public bool IsPermitted => details is { IsPermitted: true }; + public string AuthorId => details?.AuthorId; + + public void Initialize(ShareBubble shareBubble) + { + bubble = shareBubble; + avatarId = shareBubble.Data.ContentId; + bubble.SetHue(0.5f); + bubble.SetEquipButtonLabel(" Wear"); + } + + public async Task FetchContentInfo() + { + var infoResponse = await PedestalInfoBatchProcessor + .QueuePedestalInfoRequest(PedestalType.Avatar, avatarId); + + details = new BubblePedestalInfo + { + Name = infoResponse.Name, + ImageUrl = infoResponse.ImageUrl, + AuthorId = infoResponse.User.Id, + IsPermitted = infoResponse.Permitted, + IsPublic = infoResponse.Published, + }; + + downloadedTexture = await ImageCache.GetImageAsync(details.ImageUrl); + + // Check if bubble was destroyed before image was downloaded + if (bubble == null || bubble.gameObject == null) + { + Object.Destroy(downloadedTexture); + return; + } + + bubble.UpdateContent(new BubbleContentInfo + { + Name = details.Name, + Label = $" {(details.IsPublic ? "Public" : "Private")} Avatar", + Icon = downloadedTexture + }); + } + + public void HandleClaimAccept(string userId, Action onClaimActionCompleted) + { + Task.Run(async () => + { + try + { + var response = await ShareApiHelper.ShareContentAsync( + ShareApiHelper.ShareContentType.Avatar, avatarId, userId); + + // Store the temporary share to revoke when either party leaves the instance + if (bubble.Data.Access == ShareAccess.Session) + TempShareManager.Instance.AddTempShare(ShareApiHelper.ShareContentType.Avatar, + avatarId, userId); + + onClaimActionCompleted(response.IsSuccessStatusCode); + } + catch (Exception ex) + { + ShareBubblesMod.Logger.Error($"Error sharing avatar: {ex.Message}"); + onClaimActionCompleted(false); + } + }); + } + + public void ViewDetailsPage() + { + if (details == null) return; + ViewManager.Instance.RequestAvatarDetailsPage(avatarId); + } + + public void EquipContent() + { + if (details == null) return; + AssetManagement.Instance.LoadLocalAvatar(avatarId); + } + + public void Cleanup() + { + if (downloadedTexture == null) return; + Object.Destroy(downloadedTexture); + downloadedTexture = null; + } + } +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/Implementation/IShareBubbleImpl.cs b/ShareBubbles/ShareBubbles/Implementation/IShareBubbleImpl.cs new file mode 100644 index 0000000..9551717 --- /dev/null +++ b/ShareBubbles/ShareBubbles/Implementation/IShareBubbleImpl.cs @@ -0,0 +1,13 @@ +namespace NAK.ShareBubbles.Impl; + +public interface IShareBubbleImpl +{ + bool IsPermitted { get; } // Is user permitted to use the content (Public, Owned, Shared) + string AuthorId { get; } // Author ID of the content + void Initialize(ShareBubble shareBubble); + Task FetchContentInfo(); // Load the content info from the API + void ViewDetailsPage(); // Open the details page for the content + void EquipContent(); // Equip the content (Switch/Select) + void HandleClaimAccept(string userId, Action onClaimActionCompleted); // Handle the claim action (Share via API) + void Cleanup(); // Cleanup any resources +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/Implementation/SpawnableBubbleImpl.cs b/ShareBubbles/ShareBubbles/Implementation/SpawnableBubbleImpl.cs new file mode 100644 index 0000000..a5ece85 --- /dev/null +++ b/ShareBubbles/ShareBubbles/Implementation/SpawnableBubbleImpl.cs @@ -0,0 +1,122 @@ +using ABI_RC.Core.InteractionSystem; +using ABI_RC.Core.IO; +using ABI_RC.Core.Networking.API.UserWebsocket; +using ABI_RC.Core.Player; +using NAK.ShareBubbles.API; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace NAK.ShareBubbles.Impl +{ + public class SpawnableBubbleImpl : IShareBubbleImpl + { + private ShareBubble bubble; + private string spawnableId; + private BubblePedestalInfo details; + private Texture2D downloadedTexture; + + public bool IsPermitted => details is { IsPermitted: true }; + public string AuthorId => details?.AuthorId; + + public void Initialize(ShareBubble shareBubble) + { + bubble = shareBubble; + spawnableId = shareBubble.Data.ContentId; + bubble.SetHue(0f); + bubble.SetEquipButtonLabel(" Select"); + } + + public async Task FetchContentInfo() + { + var infoResponse = await PedestalInfoBatchProcessor + .QueuePedestalInfoRequest(PedestalType.Prop, spawnableId); + + details = new BubblePedestalInfo + { + Name = infoResponse.Name, + ImageUrl = infoResponse.ImageUrl, + AuthorId = infoResponse.User.Id, + IsPermitted = infoResponse.Permitted, + IsPublic = infoResponse.Published, + }; + + downloadedTexture = await ImageCache.GetImageAsync(details.ImageUrl); + + // Check if bubble was destroyed before image was downloaded + if (bubble == null || bubble.gameObject == null) + { + Object.Destroy(downloadedTexture); + return; + } + + bubble.UpdateContent(new BubbleContentInfo + { + Name = details.Name, + Label = $" {(details.IsPublic ? "Public" : "Private")} Prop", + Icon = downloadedTexture + }); + } + + public void HandleClaimAccept(string userId, Action onClaimActionCompleted) + { + if (details == null) + { + onClaimActionCompleted(false); + return; + } + + Task.Run(async () => + { + try + { + var response = await ShareApiHelper.ShareContentAsync( + ShareApiHelper.ShareContentType.Spawnable, + spawnableId, + userId); + + // Store the temporary share to revoke when either party leaves the instance + if (bubble.Data.Access == ShareAccess.Session) + TempShareManager.Instance.AddTempShare(ShareApiHelper.ShareContentType.Spawnable, + spawnableId, userId); + + onClaimActionCompleted(response.IsSuccessStatusCode); + } + catch (Exception ex) + { + ShareBubblesMod.Logger.Error($"Error sharing spawnable: {ex.Message}"); + onClaimActionCompleted(false); + } + }); + } + + public void ViewDetailsPage() + { + if (details == null) return; + ViewManager.Instance.GetPropDetails(spawnableId); + } + + public void EquipContent() + { + if (details == null || !IsPermitted) return; + + try + { + PlayerSetup.Instance.SelectPropToSpawn( + spawnableId, + details.ImageUrl, + details.Name); + } + catch (Exception ex) + { + ShareBubblesMod.Logger.Error($"Error equipping spawnable: {ex.Message}"); + } + } + + public void Cleanup() + { + if (downloadedTexture == null) return; + Object.Destroy(downloadedTexture); + downloadedTexture = null; + } + } +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/Implementation/TempShareManager.cs b/ShareBubbles/ShareBubbles/Implementation/TempShareManager.cs new file mode 100644 index 0000000..d35810a --- /dev/null +++ b/ShareBubbles/ShareBubbles/Implementation/TempShareManager.cs @@ -0,0 +1,230 @@ +using ABI_RC.Core.Networking.API.UserWebsocket; +using ABI_RC.Core.Networking.IO.Instancing; +using ABI_RC.Core.Player; +using ABI_RC.Systems.GameEventSystem; +using NAK.ShareBubbles.API; +using Newtonsoft.Json; +using UnityEngine; + +namespace NAK.ShareBubbles; + +// Debating on whether to keep this as a feature or not +// Is janky and for avatars it causes this: +// https://feedback.abinteractive.net/p/when-avatar-is-unshared-by-owner-remotes-will-see-content-incompatible-bot + +public class TempShareManager +{ + #region Constructor + + private TempShareManager() + { + string userDataPath = Path.GetFullPath(Path.Combine(Application.dataPath, "..", "UserData")); + savePath = Path.Combine(userDataPath, "sharebubbles_session_shares.json"); + } + + #endregion Constructor + + #region Singleton + + public static TempShareManager Instance { get; private set; } + + public static void Initialize() + { + if (Instance != null) return; + Instance = new TempShareManager(); + + Instance.LoadShares(); + Instance.InitializeEvents(); + } + + #endregion + + #region Constants & Fields + + private readonly string savePath; + private readonly object fileLock = new(); + + private TempShareData shareData = new(); + private readonly HashSet grantedSharesThisSession = new(); + + #endregion Constants & Fields + + #region Data Classes + + [Serializable] + private class TempShare + { + public ShareApiHelper.ShareContentType ContentType { get; set; } + public string ContentId { get; set; } + public string UserId { get; set; } + public DateTime CreatedAt { get; set; } + } + + [Serializable] + private class TempShareData + { + public List Shares { get; set; } = new(); + } + + #endregion Data Classes + + #region Public Methods + + public void AddTempShare(ShareApiHelper.ShareContentType contentType, string contentId, string userId) + { + ShareBubblesMod.Logger.Msg($"Adding temp share for {userId}..."); + + TempShare share = new() + { + ContentType = contentType, + ContentId = contentId, + UserId = userId, + CreatedAt = DateTime.UtcNow + }; + + // So we can monitor when they leave + grantedSharesThisSession.Add(userId); + + shareData.Shares.Add(share); + SaveShares(); + } + + #endregion Public Methods + + #region Event Handlers + + private void InitializeEvents() + { + CVRGameEventSystem.Instance.OnConnected.AddListener(OnConnected); + CVRGameEventSystem.Player.OnLeaveEntity.AddListener(OnPlayerLeft); + Application.quitting += OnApplicationQuit; + } + + private async void OnConnected(string _) + { + if (Instances.IsReconnecting) + return; + + if (shareData.Shares.Count == 0) + return; + + ShareBubblesMod.Logger.Msg($"Revoking {shareData.Shares.Count} shares from last session..."); + + // Attempt to revoke all shares when connecting to an online instance + // This will catch shares not revoked last session, and in prior instance + await RevokeAllShares(); + + ShareBubblesMod.Logger.Msg($"There are {shareData.Shares.Count} shares remaining."); + } + + private async void OnPlayerLeft(CVRPlayerEntity player) + { + // If they were granted shares this session, revoke them + if (grantedSharesThisSession.Contains(player.Uuid)) + await RevokeSharesForUser(player.Uuid); + } + + private void OnApplicationQuit() + { + // Attempt to revoke all shares when the game closes + RevokeAllShares().GetAwaiter().GetResult(); + } + + #endregion Event Handlers + + #region Share Management + + private async Task RevokeSharesForUser(string userId) + { + for (int i = shareData.Shares.Count - 1; i >= 0; i--) + { + TempShare share = shareData.Shares[i]; + if (share.UserId != userId) continue; + + if (!await RevokeShare(share)) + continue; + + shareData.Shares.RemoveAt(i); + SaveShares(); + } + } + + private async Task RevokeAllShares() + { + for (int i = shareData.Shares.Count - 1; i >= 0; i--) + { + if (!await RevokeShare(shareData.Shares[i])) + continue; + + shareData.Shares.RemoveAt(i); + SaveShares(); + } + } + + private async Task RevokeShare(TempShare share) + { + try + { + var response = await ShareApiHelper.ReleaseShareAsync( + share.ContentType, + share.ContentId, + share.UserId + ); + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + Debug.LogError($"Failed to revoke share: {ex.Message}"); + return false; + } + } + + #endregion Share Management + + #region File Operations + + private void LoadShares() + { + try + { + lock (fileLock) + { + if (!File.Exists(savePath)) + return; + + string json = File.ReadAllText(savePath); + shareData = JsonConvert.DeserializeObject(json) ?? new TempShareData(); + } + } + catch (Exception ex) + { + Debug.LogError($"Failed to load temp shares: {ex.Message}"); + shareData = new TempShareData(); + } + } + + private void SaveShares() + { + try + { + lock (fileLock) + { + string directory = Path.GetDirectoryName(savePath); + if (!Directory.Exists(directory)) + { + if (directory == null) throw new Exception("Failed to get directory path"); + Directory.CreateDirectory(directory); + } + + string json = JsonConvert.SerializeObject(shareData, Formatting.Indented); + File.WriteAllText(savePath, json); + } + } + catch (Exception ex) + { + Debug.LogError($"Failed to save temp shares: {ex.Message}"); + } + } + + #endregion +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/Networking/ModNetwork.Constants.cs b/ShareBubbles/ShareBubbles/Networking/ModNetwork.Constants.cs new file mode 100644 index 0000000..8aecac2 --- /dev/null +++ b/ShareBubbles/ShareBubbles/Networking/ModNetwork.Constants.cs @@ -0,0 +1,11 @@ +namespace NAK.ShareBubbles.Networking; + +public static partial class ModNetwork +{ + #region Constants + + private const string NetworkVersion = "1.0.1"; // change each time network protocol changes + private const string ModId = $"NAK.SB:{NetworkVersion}"; // Cannot exceed 32 characters + + #endregion Constants +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/Networking/ModNetwork.Enums.cs b/ShareBubbles/ShareBubbles/Networking/ModNetwork.Enums.cs new file mode 100644 index 0000000..86d9758 --- /dev/null +++ b/ShareBubbles/ShareBubbles/Networking/ModNetwork.Enums.cs @@ -0,0 +1,38 @@ +namespace NAK.ShareBubbles.Networking; + +public static partial class ModNetwork +{ + #region Enums + + // Remotes will ask owner of a bubble to share its content + // Owner can accept or deny the request, and the result will be sent back to the remote + // TODO: need rate limiting to prevent malicious users from spamming requests + + private enum MessageType : byte + { + // Lifecycle of a bubble + BubbleCreated, // bubbleId, bubbleType, position, rotation + BubbleDestroyed, // bubbleId + BubbleMoved, // bubbleId, position, rotation + + // Requesting share of a bubbles content + BubbleClaimRequest, // bubbleId + BubbleClaimResponse, // bubbleId, success + + // Requesting all active bubbles on instance join + ActiveBubblesRequest, // none + ActiveBubblesResponse, // int count, bubbleId[count], bubbleType[count], position[count], rotation[count] + + // Notification of share being sent to a user + DirectShareNotification, // userId, contentType, contentId + } + + private enum MNLogLevel : byte + { + Info = 0, + Warning = 1, + Error = 2 + } + + #endregion Enums +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/Networking/ModNetwork.Helpers.cs b/ShareBubbles/ShareBubbles/Networking/ModNetwork.Helpers.cs new file mode 100644 index 0000000..3ee6e81 --- /dev/null +++ b/ShareBubbles/ShareBubbles/Networking/ModNetwork.Helpers.cs @@ -0,0 +1,27 @@ +using ABI_RC.Core.Networking; +using DarkRift; +using UnityEngine; + +namespace NAK.ShareBubbles.Networking; + +public static partial class ModNetwork +{ + #region Private Methods + + private static bool CanSendModNetworkMessage() + => _isSubscribedToModNetwork && IsConnectedToGameNetwork(); + + private static bool IsConnectedToGameNetwork() + { + return NetworkManager.Instance != null + && NetworkManager.Instance.GameNetwork != null + && NetworkManager.Instance.GameNetwork.ConnectionState == ConnectionState.Connected; + } + + /// Checks if a Vector3 is invalid (NaN or Infinity). + private static bool IsInvalidVector3(Vector3 vector) + => float.IsNaN(vector.x) || float.IsNaN(vector.y) || float.IsNaN(vector.z) + || float.IsInfinity(vector.x) || float.IsInfinity(vector.y) || float.IsInfinity(vector.z); + + #endregion Private Methods +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/Networking/ModNetwork.Inbound.cs b/ShareBubbles/ShareBubbles/Networking/ModNetwork.Inbound.cs new file mode 100644 index 0000000..708427a --- /dev/null +++ b/ShareBubbles/ShareBubbles/Networking/ModNetwork.Inbound.cs @@ -0,0 +1,200 @@ +using ABI_RC.Core.Networking.IO.Social; +using ABI_RC.Core.Savior; +using ABI_RC.Systems.ModNetwork; +using NAK.ShareBubbles.API; +using UnityEngine; + +namespace NAK.ShareBubbles.Networking; + +public static partial class ModNetwork +{ + #region Reset Method + + public static void Reset() + { + LoggerInbound("ModNetwork has been reset."); + } + + #endregion Reset Method + + #region Inbound Methods + + private static bool ShouldReceiveFromSender(string sender) + { + // if (_disallowedForSession.Contains(sender)) + // return false; // ignore messages from disallowed users + + if (MetaPort.Instance.blockedUserIds.Contains(sender)) + return false; // ignore messages from blocked users + + // if (ModSettings.Entry_FriendsOnly.Value && !Friends.FriendsWith(sender)) + // return false; // ignore messages from non-friends if friends only is enabled + + // if (StickerSystem.Instance.IsRestrictedInstance) // ignore messages from users when the world is restricted. This also includes older or modified version of Stickers mod. + // return false; + + return true; + } + + private static void HandleMessageReceived(ModNetworkMessage msg) + { + try + { + string sender = msg.Sender; + msg.Read(out byte msgTypeRaw); + + if (!Enum.IsDefined(typeof(MessageType), msgTypeRaw)) + return; + + if (!ShouldReceiveFromSender(sender)) + return; + + LoggerInbound($"Received message from {msg.Sender}, Type: {(MessageType)msgTypeRaw}"); + + switch ((MessageType)msgTypeRaw) + { + case MessageType.BubbleCreated: + HandleBubbleCreated(msg); + break; + case MessageType.BubbleDestroyed: + HandleBubbleDestroyed(msg); + break; + case MessageType.BubbleMoved: + HandleBubbleMoved(msg); + break; + case MessageType.BubbleClaimRequest: + HandleBubbleClaimRequest(msg); + break; + case MessageType.BubbleClaimResponse: + HandleBubbleClaimResponse(msg); + break; + case MessageType.ActiveBubblesRequest: + HandleActiveBubblesRequest(msg); + break; + case MessageType.ActiveBubblesResponse: + HandleActiveBubblesResponse(msg); + break; + case MessageType.DirectShareNotification: + HandleDirectShareNotification(msg); + break; + default: + LoggerInbound($"Invalid message type received: {msgTypeRaw}"); + break; + } + } + catch (Exception e) + { + LoggerInbound($"Error handling message from {msg.Sender}: {e.Message}", MNLogLevel.Warning); + } + } + + private static void HandleBubbleCreated(ModNetworkMessage msg) + { + msg.Read(out Vector3 position); + msg.Read(out Vector3 rotation); + msg.Read(out ShareBubbleData data); + + // Check if the position or rotation is invalid + if (IsInvalidVector3(position) + || IsInvalidVector3(rotation)) + return; + + // Check if we should ignore this bubble + // Client enforced, sure, but only really matters for share claim, which is requires bubble owner to verify + if (data.Rule == ShareRule.FriendsOnly && !Friends.FriendsWith(msg.Sender)) + { + LoggerInbound($"Bubble with ID {data.BubbleId} is FriendsOnly and sender is not a friend, ignoring."); + return; + } + + ShareBubbleManager.Instance.OnRemoteBubbleCreated(msg.Sender, position, rotation, data); + + LoggerInbound($"Bubble with ID {data.BubbleId} created at {position}"); + } + + private static void HandleBubbleDestroyed(ModNetworkMessage msg) + { + msg.Read(out uint bubbleNetworkId); + + // Destroy bubble + ShareBubbleManager.Instance.OnRemoteBubbleDestroyed(msg.Sender, bubbleNetworkId); + + LoggerInbound($"Bubble with ID {bubbleNetworkId} destroyed"); + } + + + private static void HandleBubbleMoved(ModNetworkMessage msg) + { + msg.Read(out uint bubbleId); + msg.Read(out Vector3 position); + msg.Read(out Vector3 rotation); + + // Check if the position or rotation is invalid + if (IsInvalidVector3(position) + || IsInvalidVector3(rotation)) + return; + + ShareBubbleManager.Instance.OnRemoteBubbleMoved(msg.Sender, bubbleId, position, rotation); + LoggerInbound($"Bubble {bubbleId} moved to {position}"); + } + + private static void HandleBubbleClaimRequest(ModNetworkMessage msg) + { + msg.Read(out uint bubbleNetworkId); + + ShareBubbleManager.Instance.OnRemoteBubbleClaimRequest(msg.Sender, bubbleNetworkId); + + LoggerInbound($"Bubble with ID {bubbleNetworkId} claimed by {msg.Sender}"); + } + + private static void HandleBubbleClaimResponse(ModNetworkMessage msg) + { + msg.Read(out uint bubbleNetworkId); + msg.Read(out bool claimAccepted); + + ShareBubbleManager.Instance.OnRemoteBubbleClaimResponse(msg.Sender, bubbleNetworkId, claimAccepted); + + LoggerInbound($"Bubble with ID {bubbleNetworkId} claim response: {claimAccepted}"); + } + + private static void HandleActiveBubblesRequest(ModNetworkMessage msg) + { + LoggerInbound($"Received ActiveBubblesRequest from {msg.Sender}"); + + ShareBubbleManager.Instance.OnRemoteActiveBubbleRequest(msg.Sender); + } + + private static void HandleActiveBubblesResponse(ModNetworkMessage msg) + { + try + { + // hacky, but im tired and didnt think + ShareBubbleManager.Instance.SetRemoteBatchCreateBubbleState(msg.Sender, true); + + msg.Read(out int bubbleCount); + LoggerInbound($"Received ActiveBubblesResponse from {msg.Sender} with {bubbleCount} bubbles"); + + // Create up to MaxBubblesPerUser bubbles + for (int i = 0; i < Mathf.Min(bubbleCount, ShareBubbleManager.MaxBubblesPerUser); i++) + HandleBubbleCreated(msg); + } + catch (Exception e) + { + LoggerInbound($"Error handling ActiveBubblesResponse from {msg.Sender}: {e.Message}", MNLogLevel.Warning); + } + finally + { + ShareBubbleManager.Instance.SetRemoteBatchCreateBubbleState(msg.Sender, false); + } + } + + private static void HandleDirectShareNotification(ModNetworkMessage msg) + { + msg.Read(out ShareApiHelper.ShareContentType contentType); + msg.Read(out string contentId); + + LoggerInbound($"Received DirectShareNotification from {msg.Sender} for {contentType} {contentId}"); + } + + #endregion Inbound Methods +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/Networking/ModNetwork.Logging.cs b/ShareBubbles/ShareBubbles/Networking/ModNetwork.Logging.cs new file mode 100644 index 0000000..4f55048 --- /dev/null +++ b/ShareBubbles/ShareBubbles/Networking/ModNetwork.Logging.cs @@ -0,0 +1,32 @@ +namespace NAK.ShareBubbles.Networking; + +public static partial class ModNetwork +{ + #region Network Logging + + private static void LoggerInbound(string message, MNLogLevel type = MNLogLevel.Info) + => _logger($"[Inbound] {message}", type, ModSettings.Debug_NetworkInbound.Value); + + private static void LoggerOutbound(string message, MNLogLevel type = MNLogLevel.Info) + => _logger($"[Outbound] {message}", type, ModSettings.Debug_NetworkOutbound.Value); + + private static void _logger(string message, MNLogLevel type = MNLogLevel.Info, bool loggerSetting = true) + { + switch (type) + { + default: + case MNLogLevel.Info when loggerSetting: + ShareBubblesMod.Logger.Msg(message); + break; + case MNLogLevel.Warning when loggerSetting: + ShareBubblesMod.Logger.Warning(message); + break; + case MNLogLevel.Error: // Error messages are always logged, regardless of setting + ShareBubblesMod.Logger.Error(message); + break; + + } + } + + #endregion Network Logging +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/Networking/ModNetwork.Main.cs b/ShareBubbles/ShareBubbles/Networking/ModNetwork.Main.cs new file mode 100644 index 0000000..2097211 --- /dev/null +++ b/ShareBubbles/ShareBubbles/Networking/ModNetwork.Main.cs @@ -0,0 +1,36 @@ +using ABI_RC.Systems.ModNetwork; + +namespace NAK.ShareBubbles.Networking; + +public static partial class ModNetwork +{ + #region Mod Network Internals + + private static bool _isSubscribedToModNetwork; + + internal static void Initialize() + { + // Packs the share bubble data a bit, also makes things just nicer + ShareBubbleData.AddConverterForModNetwork(); + } + + internal static void Subscribe() + { + ModNetworkManager.Subscribe(ModId, HandleMessageReceived); + + _isSubscribedToModNetwork = ModNetworkManager.IsSubscribed(ModId); + if (!_isSubscribedToModNetwork) ShareBubblesMod.Logger.Error("Failed to subscribe to Mod Network! This should not happen."); + else ShareBubblesMod.Logger.Msg("Subscribed to Mod Network."); + } + + internal static void Unsubscribe() + { + ModNetworkManager.Unsubscribe(ModId); + + _isSubscribedToModNetwork = ModNetworkManager.IsSubscribed(ModId); + if (_isSubscribedToModNetwork) ShareBubblesMod.Logger.Error("Failed to unsubscribe from Mod Network! This should not happen."); + else ShareBubblesMod.Logger.Msg("Unsubscribed from Mod Network."); + } + + #endregion Mod Network Internals +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/Networking/ModNetwork.Outbound.cs b/ShareBubbles/ShareBubbles/Networking/ModNetwork.Outbound.cs new file mode 100644 index 0000000..8f2d85d --- /dev/null +++ b/ShareBubbles/ShareBubbles/Networking/ModNetwork.Outbound.cs @@ -0,0 +1,130 @@ +using ABI_RC.Systems.ModNetwork; +using NAK.ShareBubbles.API; +using UnityEngine; + +namespace NAK.ShareBubbles.Networking; + +public static partial class ModNetwork +{ + #region Outbound Methods + + public static void SendBubbleCreated(Vector3 position, Quaternion rotation, ShareBubbleData data) + { + if (!CanSendModNetworkMessage()) + return; + + using ModNetworkMessage modMsg = new(ModId); + modMsg.Write((byte)MessageType.BubbleCreated); + modMsg.Write(position); + modMsg.Write(rotation.eulerAngles); + modMsg.Write(data); + modMsg.Send(); + + LoggerOutbound($"Sending BubbleCreated message for bubble {data.BubbleId}"); + } + + public static void SendBubbleDestroyed(uint bubbleNetworkId) + { + if (!CanSendModNetworkMessage()) + return; + + using ModNetworkMessage modMsg = new(ModId); + modMsg.Write((byte)MessageType.BubbleDestroyed); + modMsg.Write(bubbleNetworkId); + modMsg.Send(); + + LoggerOutbound($"Sending BubbleDestroyed message for bubble {bubbleNetworkId}"); + } + + public static void SendBubbleMove(int bubbleId, Vector3 position, Quaternion rotation) + { + if (!CanSendModNetworkMessage()) + return; + + using ModNetworkMessage msg = new(ModId); + msg.Write((byte)MessageType.BubbleMoved); + msg.Write(bubbleId); + msg.Write(position); + msg.Write(rotation.eulerAngles); + msg.Send(); + + LoggerOutbound($"Sending BubbleMove message for bubble {bubbleId}"); + } + + public static void SendBubbleClaimRequest(string bubbleOwnerId, uint bubbleNetworkId) + { + if (!CanSendModNetworkMessage()) + return; + + using ModNetworkMessage modMsg = new(ModId, bubbleOwnerId); + modMsg.Write((byte)MessageType.BubbleClaimRequest); + modMsg.Write(bubbleNetworkId); + modMsg.Send(); + + LoggerOutbound($"Sending BubbleClaimRequest message for bubble {bubbleNetworkId}"); + } + + public static void SendBubbleClaimResponse(string requesterUserId, uint bubbleNetworkId, bool success) + { + if (!CanSendModNetworkMessage()) + return; + + using ModNetworkMessage modMsg = new(ModId, requesterUserId); + modMsg.Write((byte)MessageType.BubbleClaimResponse); + modMsg.Write(bubbleNetworkId); + modMsg.Write(success); + modMsg.Send(); + + LoggerOutbound($"Sending BubbleClaimResponse message for bubble {bubbleNetworkId}"); + } + + public static void SendActiveBubblesRequest() + { + if (!CanSendModNetworkMessage()) + return; + + using ModNetworkMessage modMsg = new(ModId); + modMsg.Write((byte)MessageType.ActiveBubblesRequest); + modMsg.Send(); + + LoggerOutbound("Sending ActiveBubblesRequest message"); + } + + public static void SendActiveBubblesResponse(string requesterUserId, List activeBubbles) + { + if (!CanSendModNetworkMessage()) + return; + + using ModNetworkMessage modMsg = new(ModId, requesterUserId); + modMsg.Write((byte)MessageType.ActiveBubblesResponse); + modMsg.Write(activeBubbles.Count); + + foreach (ShareBubble bubble in activeBubbles) + { + Transform parent = bubble.transform.parent; + modMsg.Write(parent.position); + modMsg.Write(parent.rotation.eulerAngles); + modMsg.Write(bubble.Data); + } + + modMsg.Send(); + + LoggerOutbound($"Sending ActiveBubblesResponse message with {activeBubbles.Count} bubbles"); + } + + public static void SendDirectShareNotification(string userId, ShareApiHelper.ShareContentType contentType, string contentId) + { + if (!CanSendModNetworkMessage()) + return; + + using ModNetworkMessage modMsg = new(ModId, userId); + modMsg.Write((byte)MessageType.DirectShareNotification); + modMsg.Write(contentType); + modMsg.Write(contentId); + modMsg.Send(); + + LoggerOutbound($"Sending DirectShareNotification message for {userId}"); + } + + #endregion Outbound Methods +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/ShareBubble.cs b/ShareBubbles/ShareBubbles/ShareBubble.cs new file mode 100644 index 0000000..5ab9139 --- /dev/null +++ b/ShareBubbles/ShareBubbles/ShareBubble.cs @@ -0,0 +1,363 @@ +using UnityEngine; +using ABI_RC.Core.Networking; +using ABI_RC.Core.Networking.IO.Social; +using ABI_RC.Core.Player; +using ABI_RC.Core.Savior; +using NAK.ShareBubbles.Impl; +using NAK.ShareBubbles.Networking; +using NAK.ShareBubbles.UI; +using TMPro; +using System.Collections; + +namespace NAK.ShareBubbles; + +public struct BubbleContentInfo +{ + public string Name { get; set; } + public string Label { get; set; } + public Texture2D Icon { get; set; } +} + +public class ShareBubble : MonoBehaviour +{ + // Shader properties + private static readonly int _MatcapHueShiftId = Shader.PropertyToID("_MatcapHueShift"); + private static readonly int _MatcapReplaceId = Shader.PropertyToID("_MatcapReplace"); + + #region Enums + + // State of fetching content info from api + private enum InfoFetchState + { + Fetching, // Fetching content info from api + Error, // Content info fetch failed + Ready // Content info fetched successfully + } + + // State of asking for content claim from owner + private enum ClaimState + { + Waiting, // No claim request sent + Requested, // Claim request sent to owner, waiting for response + Rejected, // Claim request rejected by owner + Permitted, // Claim request accepted by owner, or content is already unlocked + } + + #endregion Enums + + #region Properties + + private InfoFetchState _currentApiState; + + private InfoFetchState CurrentApiState + { + get => _currentApiState; + set + { + _currentApiState = value; + UpdateVisualState(); + } + } + + private ClaimState _currentClaimState; + + private ClaimState CurrentClaimState + { + get => _currentClaimState; + set + { + _currentClaimState = value; + UpdateClaimState(); + } + } + + public bool IsDestroyed { get; set; } + public string OwnerId; + public ShareBubbleData Data { get; private set; } + public bool IsOwnBubble => OwnerId == MetaPort.Instance.ownerId; + public bool IsPermitted => (implementation?.IsPermitted ?? false) || CurrentClaimState == ClaimState.Permitted; + + #endregion Properties + + #region Private Fields + + private IShareBubbleImpl implementation; + private DateTime? lastClaimRequest; + private const float ClaimTimeout = 30f; + private Coroutine claimStateResetCoroutine; + + private float lifetimeTotal; + + #endregion Private Fields + + #region Serialized Fields + + [Header("Visual Components")] + [SerializeField] private Renderer hexRenderer; + [SerializeField] private Renderer iconRenderer; + [SerializeField] private TextMeshPro droppedByText; + [SerializeField] private TextMeshPro contentName; + [SerializeField] private TextMeshPro contentLabel; + [SerializeField] private BubbleAnimController animController; + + [Header("State Objects")] + [SerializeField] private GameObject loadingState; + [SerializeField] private GameObject lockedState; + [SerializeField] private GameObject errorState; + [SerializeField] private GameObject contentInfo; + + [Header("Button UI")] + [SerializeField] private TextMeshProUGUI equipButtonLabel; + [SerializeField] private TextMeshProUGUI claimButtonLabel; + + #endregion Serialized Fields + + #region Public Methods + + public void SetEquipButtonLabel(string label) + { + equipButtonLabel.text = label; + } + + #endregion Public Methods + + #region Lifecycle Methods + + public async void Initialize(ShareBubbleData data, IShareBubbleImpl impl) + { + animController = GetComponent(); + + Data = data; + implementation = impl; + implementation.Initialize(this); + + CurrentApiState = InfoFetchState.Fetching; + CurrentClaimState = ClaimState.Waiting; + + string playerName = IsOwnBubble + ? AuthManager.Username + : CVRPlayerManager.Instance.TryGetPlayerName(OwnerId); + + droppedByText.text = $"Dropped by\n{playerName}"; + + if (Data.Lifetime == ShareLifetime.TwoMinutes) + lifetimeTotal = 120f; + + try + { + await implementation.FetchContentInfo(); + if (this == null || gameObject == null) return; // Bubble was destroyed during fetch + CurrentApiState = InfoFetchState.Ready; + } + catch (Exception ex) + { + ShareBubblesMod.Logger.Error($"Failed to load content info: {ex}"); + if (this == null || gameObject == null) return; // Bubble was destroyed during fetch + CurrentApiState = InfoFetchState.Error; + } + } + + private void Update() + { + if (Data.Lifetime == ShareLifetime.Session) + return; + + float lifetimeElapsed = (float)(DateTime.UtcNow - Data.CreatedAt).TotalSeconds; + float lifetimeProgress = Mathf.Clamp01(lifetimeElapsed / lifetimeTotal); + animController.SetLifetimeVisual(lifetimeProgress); + if (lifetimeProgress >= 1f) + { + //ShareBubblesMod.Logger.Msg($"Bubble expired: {Data.BubbleId}"); + Destroy(gameObject); + } + } + + private void OnDestroy() + { + implementation?.Cleanup(); + if (ShareBubbleManager.Instance != null && !IsDestroyed) + ShareBubbleManager.Instance.OnBubbleDestroyed(OwnerId, Data.BubbleId); + } + + #endregion Lifecycle Methods + + #region Visual State Management + + private void UpdateVisualState() + { + loadingState.SetActive(CurrentApiState == InfoFetchState.Fetching); + errorState.SetActive(CurrentApiState == InfoFetchState.Error); + contentInfo.SetActive(CurrentApiState == InfoFetchState.Ready); + + hexRenderer.material.SetFloat(_MatcapReplaceId, + CurrentApiState == InfoFetchState.Fetching ? 0f : 1f); + + if (CurrentApiState == InfoFetchState.Ready) + { + if (IsPermitted) CurrentClaimState = ClaimState.Permitted; + animController.ShowHubPivot(); + UpdateButtonStates(); + } + } + + private void UpdateButtonStates() + { + bool canClaim = !IsPermitted && Data.Access != ShareAccess.None && CanRequestClaim(); + bool canEquip = IsPermitted && CurrentApiState == InfoFetchState.Ready; + + claimButtonLabel.transform.parent.gameObject.SetActive(canClaim); // Only show claim button if content is locked & claimable + equipButtonLabel.transform.parent.gameObject.SetActive(canEquip); // Only show equip button if content is unlocked + + if (canClaim) UpdateClaimButtonState(); + } + + private void UpdateClaimButtonState() + { + switch (CurrentClaimState) + { + case ClaimState.Requested: + claimButtonLabel.text = "Claiming..."; + break; + case ClaimState.Rejected: + claimButtonLabel.text = "Denied :("; + StartClaimStateResetTimer(); + break; + case ClaimState.Permitted: + claimButtonLabel.text = "Claimed!"; + StartClaimStateResetTimer(); + break; + default: + claimButtonLabel.text = " Claim"; + break; + } + } + + private void StartClaimStateResetTimer() + { + if (claimStateResetCoroutine != null) StopCoroutine(claimStateResetCoroutine); + claimStateResetCoroutine = StartCoroutine(ResetClaimStateAfterDelay()); + } + + private IEnumerator ResetClaimStateAfterDelay() + { + yield return new WaitForSeconds(2f); + CurrentClaimState = ClaimState.Waiting; + UpdateClaimButtonState(); + } + + private void UpdateClaimState() + { + bool isLocked = !IsPermitted && CurrentApiState == InfoFetchState.Ready; + lockedState.SetActive(isLocked); // Only show locked state if content is locked & ready + UpdateButtonStates(); + } + + #endregion Visual State Management + + #region Content Info Updates + + public void SetHue(float hue) + { + hexRenderer.material.SetFloat(_MatcapHueShiftId, hue); + } + + public void UpdateContent(BubbleContentInfo info) + { + contentName.text = info.Name; + contentLabel.text = info.Label; + if (info.Icon != null) + iconRenderer.material.mainTexture = info.Icon; + } + + #endregion Content Info Updates + + #region Interaction Methods + + public void ViewDetailsPage() + { + if (CurrentApiState != InfoFetchState.Ready) return; + implementation?.ViewDetailsPage(); + } + + public void EquipContent() + { + // So uh, selecting a prop in will also spawn it as interact is down in same frame + // Too lazy to fix so we gonna wait a frame before actually equipping :) + StartCoroutine(ActuallyEquipContentNextFrame()); + + return; + IEnumerator ActuallyEquipContentNextFrame() + { + yield return null; // Wait a frame + + if (CurrentApiState != InfoFetchState.Ready) yield break; + + if (CanRequestClaim()) // Only possible on hold & click, as button is hidden when not permitted + { + RequestContentClaim(); + yield break; + } + + if (!IsPermitted) + yield break; + + implementation?.EquipContent(); + } + } + + public void RequestContentClaim() + { + if (!CanRequestClaim()) return; + if (!RequestClaimTimeoutInactive()) return; + + lastClaimRequest = DateTime.Now; + ModNetwork.SendBubbleClaimRequest(OwnerId, Data.BubbleId); + CurrentClaimState = ClaimState.Requested; + + return; + bool RequestClaimTimeoutInactive() + { + if (!lastClaimRequest.HasValue) return true; + TimeSpan timeSinceLastRequest = DateTime.Now - lastClaimRequest.Value; + return timeSinceLastRequest.TotalSeconds >= ClaimTimeout; + } + } + + private bool CanRequestClaim() + { + if (IsPermitted) return false; + if (IsOwnBubble) return false; + return OwnerId == implementation.AuthorId; + } + + #endregion Interaction Methods + + #region Mod Network Callbacks + + public void OnClaimResponseReceived(bool accepted) + { + lastClaimRequest = null; + CurrentClaimState = accepted ? ClaimState.Permitted : ClaimState.Rejected; + UpdateButtonStates(); + } + + public void OnRemoteWantsClaim(string requesterId) + { + if (!IsOwnBubble) return; + + bool isAllowed = implementation.IsPermitted || + Data.Rule == ShareRule.Everyone || + (Data.Rule == ShareRule.FriendsOnly && Friends.FriendsWith(requesterId)); + + if (!isAllowed) + { + ModNetwork.SendBubbleClaimResponse(requesterId, Data.BubbleId, false); + return; + } + + implementation.HandleClaimAccept(requesterId, + wasAccepted => ModNetwork.SendBubbleClaimResponse(requesterId, Data.BubbleId, wasAccepted)); + } + + #endregion Mod Network Callbacks +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/ShareBubbleManager.cs b/ShareBubbles/ShareBubbles/ShareBubbleManager.cs new file mode 100644 index 0000000..2aeaec8 --- /dev/null +++ b/ShareBubbles/ShareBubbles/ShareBubbleManager.cs @@ -0,0 +1,424 @@ +using ABI_RC.Core.Networking.IO.Instancing; +using ABI_RC.Core.Player; +using ABI_RC.Core.Savior; +using ABI_RC.Core.UI; +using ABI_RC.Systems.GameEventSystem; +using ABI_RC.Systems.Gravity; +using NAK.ShareBubbles.Impl; +using NAK.ShareBubbles.Networking; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace NAK.ShareBubbles; + +public class ShareBubbleManager +{ + #region Constants + + public const int MaxBubblesPerUser = 3; + private const float BubbleCreationCooldown = 1f; + + #endregion Constants + + #region Singleton + + public static ShareBubbleManager Instance { get; private set; } + + public static void Initialize() + { + if (Instance != null) + return; + + Instance = new ShareBubbleManager(); + RegisterDefaultBubbleTypes(); + + CVRGameEventSystem.Initialization.OnPlayerSetupStart.AddListener(Instance.OnPlayerSetupStart); + } + + private static void RegisterDefaultBubbleTypes() + { + ShareBubbleRegistry.RegisterBubbleType(GetMaskedHash("Avatar"), () => new AvatarBubbleImpl()); + ShareBubbleRegistry.RegisterBubbleType(GetMaskedHash("Spawnable"), () => new SpawnableBubbleImpl()); + } + + public static uint GetMaskedHash(string typeName) => StringToHash(typeName) & 0xFFFF; + + public static uint GenerateBubbleId(string content, uint implTypeHash) + { + // Allows us to extract the implementation type hash from the bubble ID + return (implTypeHash << 16) | GetMaskedHash(content); + } + + private static uint StringToHash(string str) + { + unchecked + { + uint hash = 5381; + foreach (var ch in str) hash = ((hash << 5) + hash) + ch; + return hash; + } + } + + #endregion + + #region Fields + + private class PlayerBubbleData + { + // Can only ever be true when the batched bubble message is what created the player data + public bool IgnoreBubbleCreationCooldown; + public float LastBubbleCreationTime; + public readonly Dictionary BubblesByLocalId = new(); + } + + private readonly Dictionary playerBubbles = new(); + + public bool IsPlacingBubbleMode { get; set; } + private ShareBubbleData selectedBubbleData; + + #endregion Fields + + #region Game Events + + private void OnPlayerSetupStart() + { + CVRGameEventSystem.Instance.OnConnected.AddListener(OnConnected); + CVRGameEventSystem.Player.OnLeaveEntity.AddListener(OnPlayerLeft); + } + + private void OnConnected(string _) + { + if (Instances.IsReconnecting) + return; + + // Clear all bubbles on disconnect (most should have died during world unload) + foreach (PlayerBubbleData playerData in playerBubbles.Values) + { + foreach (ShareBubble bubble in playerData.BubblesByLocalId.Values.ToList()) + DestroyBubble(bubble); + } + playerBubbles.Clear(); + + // This also acts to signal to other clients we rejoined and need our bubbles cleared + ModNetwork.SendActiveBubblesRequest(); + } + + private void OnPlayerLeft(CVRPlayerEntity player) + { + if (!playerBubbles.TryGetValue(player.Uuid, out PlayerBubbleData playerData)) + return; + + foreach (ShareBubble bubble in playerData.BubblesByLocalId.Values.ToList()) + DestroyBubble(bubble); + + playerBubbles.Remove(player.Uuid); + } + + #endregion Game Events + + #region Local Operations + + public void DropBubbleInFront(ShareBubbleData data) + { + PlayerSetup localPlayer = PlayerSetup.Instance; + string localPlayerId = MetaPort.Instance.ownerId; + + if (!CanPlayerCreateBubble(localPlayerId)) + { + ShareBubblesMod.Logger.Msg("Bubble creation on cooldown!"); + return; + } + + float playSpaceScale = localPlayer.GetPlaySpaceScale(); + Vector3 playerForward = localPlayer.GetPlayerForward(); + Vector3 position = localPlayer.activeCam.transform.position + playerForward * 0.5f * playSpaceScale; + + if (Physics.Raycast(position, + localPlayer.CharacterController.GetGravityDirection(), + out RaycastHit raycastHit, 4f, localPlayer.dropPlacementMask)) + { + CreateBubbleForPlayer(localPlayerId, raycastHit.point, + Quaternion.LookRotation(playerForward, raycastHit.normal), data); + return; + } + + CreateBubbleForPlayer(localPlayerId, position, + Quaternion.LookRotation(playerForward, -localPlayer.transform.up), data); + } + + public void SelectBubbleForPlace(string contentCouiPath, string contentName, ShareBubbleData data) + { + selectedBubbleData = data; + IsPlacingBubbleMode = true; + CohtmlHud.Instance.SelectPropToSpawn( + contentCouiPath, + contentName, + "Selected content to bubble:"); + } + + public void PlaceSelectedBubbleFromControllerRay(Transform transform) + { + Vector3 position = transform.position; + Vector3 forward = transform.forward; + + // Every layer other than IgnoreRaycast, PlayerLocal, PlayerClone, PlayerNetwork, and UI Internal + const int LayerMask = ~((1 << 2) | (1 << 8) | (1 << 9) | (1 << 10) | (1 << 15)); + if (!Physics.Raycast(position, forward, out RaycastHit hit, + 10f, LayerMask, QueryTriggerInteraction.Ignore)) + return; // No hit + + if (selectedBubbleData.ImplTypeHash == 0) + return; + + Vector3 bubblePos = hit.point; + + PlayerSetup localPlayer = PlayerSetup.Instance; + Vector3 playerPos = localPlayer.GetPlayerPosition(); + + // Sample gravity at bubble position to get bubble orientation + Vector3 bubbleUp = -GravitySystem.TryGetResultingGravity(bubblePos, false).AppliedGravity.normalized; + Vector3 bubbleForward = Vector3.ProjectOnPlane((playerPos - bubblePos).normalized, bubbleUp); + + // Bubble faces player aligned with gravity + Quaternion bubbleRot = Quaternion.LookRotation(bubbleForward, bubbleUp); + + CreateBubbleForPlayer(MetaPort.Instance.ownerId, bubblePos, bubbleRot, selectedBubbleData); + } + + #endregion Local Operations + + #region Player Callbacks + + private void CreateBubbleForPlayer( + string playerId, + Vector3 position, + Quaternion rotation, + ShareBubbleData data) + { + GameObject bubbleRootObject = null; + try + { + if (!CanPlayerCreateBubble(playerId)) + return; + + if (!ShareBubbleRegistry.TryCreateImplementation(data.ImplTypeHash, out IShareBubbleImpl impl)) + { + Debug.LogError($"Failed to create bubble: Unknown bubble type hash: {data.ImplTypeHash}"); + return; + } + + // Get or create player data + if (!playerBubbles.TryGetValue(playerId, out PlayerBubbleData playerData)) + { + playerData = new PlayerBubbleData(); + playerBubbles[playerId] = playerData; + } + + // If a bubble with this ID already exists for this player, destroy it + if (playerData.BubblesByLocalId.TryGetValue(data.BubbleId, out ShareBubble existingBubble)) + { + ShareBubblesMod.Logger.Msg($"Replacing existing bubble: {data.BubbleId}"); + DestroyBubble(existingBubble); + } + // Check bubble limit only if we're not replacing an existing bubble + else if (playerData.BubblesByLocalId.Count >= MaxBubblesPerUser) + { + ShareBubble oldestBubble = playerData.BubblesByLocalId.Values + .OrderBy(b => b.Data.CreatedAt) + .First(); + ShareBubblesMod.Logger.Msg($"Bubble limit reached, destroying oldest bubble: {oldestBubble.Data.BubbleId}"); + DestroyBubble(oldestBubble); + } + + bubbleRootObject = Object.Instantiate(ShareBubblesMod.SharingBubblePrefab); + bubbleRootObject.transform.SetLocalPositionAndRotation(position, rotation); + bubbleRootObject.SetActive(true); + + Transform bubbleTransform = bubbleRootObject.transform.GetChild(0); + if (bubbleTransform == null) + throw new InvalidOperationException("Bubble prefab is missing expected child transform"); + + (float targetHeight, float scaleModifier) = GetBubbleHeightAndScale(); + bubbleTransform.localPosition = new Vector3(0f, targetHeight, 0f); + bubbleTransform.localScale = new Vector3(scaleModifier, scaleModifier, scaleModifier); + + ShareBubble bubble = bubbleRootObject.GetComponent(); + if (bubble == null) + throw new InvalidOperationException("Bubble prefab is missing ShareBubble component"); + + bubble.OwnerId = playerId; + bubble.Initialize(data, impl); + playerData.BubblesByLocalId[data.BubbleId] = bubble; + playerData.LastBubbleCreationTime = Time.time; + + if (playerId == MetaPort.Instance.ownerId) + ModNetwork.SendBubbleCreated(position, rotation, data); + } + catch (Exception ex) + { + Debug.LogError($"Failed to create bubble (ID: {data.BubbleId}, Owner: {playerId}): {ex}"); + + // Clean up the partially created bubble if it exists + if (bubbleRootObject != null) + { + Object.DestroyImmediate(bubbleRootObject); + + // Remove from player data if it was added + if (playerBubbles.TryGetValue(playerId, out PlayerBubbleData playerData)) + playerData.BubblesByLocalId.Remove(data.BubbleId); + } + } + } + + public void DestroyBubble(ShareBubble bubble) + { + if (bubble == null) return; + bubble.IsDestroyed = true; // Prevents ShareBubble.OnDestroy invoking OnBubbleDestroyed + Object.DestroyImmediate(bubble.gameObject); + OnBubbleDestroyed(bubble.OwnerId, bubble.Data.BubbleId); + } + + public void OnBubbleDestroyed(string ownerId, uint bubbleId) + { + if (playerBubbles.TryGetValue(ownerId, out PlayerBubbleData playerData)) + playerData.BubblesByLocalId.Remove(bubbleId); + + if (ownerId == MetaPort.Instance.ownerId) ModNetwork.SendBubbleDestroyed(bubbleId); + } + + #endregion + + #region Remote Events + + public void SetRemoteBatchCreateBubbleState(string ownerId, bool batchCreateBubbleState) + { + // Get or create player data + if (!playerBubbles.TryGetValue(ownerId, out PlayerBubbleData playerData)) + { + playerData = new PlayerBubbleData(); + playerBubbles[ownerId] = playerData; + + // If player data didn't exist, ignore bubble creation cooldown for the time we create active bubbles on join + playerData.IgnoreBubbleCreationCooldown = batchCreateBubbleState; + } + else + { + // If player data already exists, ignore batched bubble creation + // Probably makes a race condition here, but it's not critical + // This will prevent users from using the batched bubble creation when they've already created bubbles + playerData.IgnoreBubbleCreationCooldown = false; + } + } + + public void OnRemoteBubbleCreated(string ownerId, Vector3 position, Vector3 rotation, ShareBubbleData data) + { + CreateBubbleForPlayer(ownerId, position, Quaternion.Euler(rotation), data); + } + + public void OnRemoteBubbleDestroyed(string ownerId, uint bubbleId) + { + if (!playerBubbles.TryGetValue(ownerId, out PlayerBubbleData playerData) || + !playerData.BubblesByLocalId.TryGetValue(bubbleId, out ShareBubble bubble)) + return; + + DestroyBubble(bubble); + } + + public void OnRemoteBubbleMoved(string ownerId, uint bubbleId, Vector3 position, Vector3 rotation) + { + if (!playerBubbles.TryGetValue(ownerId, out PlayerBubbleData playerData) || + !playerData.BubblesByLocalId.TryGetValue(bubbleId, out ShareBubble bubble)) + return; + + // TODO: fix + bubble.transform.parent.SetPositionAndRotation(position, Quaternion.Euler(rotation)); + } + + public void OnRemoteBubbleClaimRequest(string requesterUserId, uint bubbleId) + { + // Get our bubble data if exists, respond to requester + string ownerId = MetaPort.Instance.ownerId; + if (!playerBubbles.TryGetValue(ownerId, out PlayerBubbleData playerData) || + !playerData.BubblesByLocalId.TryGetValue(bubbleId, out ShareBubble bubble)) + return; + + bubble.OnRemoteWantsClaim(requesterUserId); + } + + public void OnRemoteBubbleClaimResponse(string ownerId, uint bubbleId, bool wasAccepted) + { + // Get senders bubble data if exists, receive response + if (!playerBubbles.TryGetValue(ownerId, out PlayerBubbleData playerData) || + !playerData.BubblesByLocalId.TryGetValue(bubbleId, out ShareBubble bubble)) + return; + + bubble.OnClaimResponseReceived(wasAccepted); + } + + public void OnRemoteActiveBubbleRequest(string requesterUserId) + { + // Clear all bubbles for requester (as this msg is sent by them on initial join) + // This catches the case where they rejoin faster than we heard their leave event + if (playerBubbles.TryGetValue(requesterUserId, out PlayerBubbleData playerData)) + { + foreach (ShareBubble bubble in playerData.BubblesByLocalId.Values.ToList()) + DestroyBubble(bubble); + } + + var myBubbles = GetOwnActiveBubbles(); + if (myBubbles.Count == 0) + return; // No bubbles to send + + // Send all active bubbles to requester + ModNetwork.SendActiveBubblesResponse(requesterUserId, myBubbles); + } + + #endregion + + #region Utility Methods + + private bool CanPlayerCreateBubble(string playerId) + { + if (!playerBubbles.TryGetValue(playerId, out PlayerBubbleData playerData)) + return true; + + if (playerData.IgnoreBubbleCreationCooldown) + return true; // Only ignore for the time we create active bubbles on join + + return Time.time - playerData.LastBubbleCreationTime >= BubbleCreationCooldown; + } + + private static (float, float) GetBubbleHeightAndScale() + { + float targetHeight = 0.5f * PlayerSetup.Instance.GetAvatarHeight(); + float scaleModifier = PlayerSetup.Instance.GetPlaySpaceScale() * 1.8f; + return (targetHeight, scaleModifier); + } + + public void OnPlayerScaleChanged() + { + (float targetHeight, float scaleModifier) = GetBubbleHeightAndScale(); + + foreach (PlayerBubbleData playerData in playerBubbles.Values) + { + foreach (ShareBubble bubble in playerData.BubblesByLocalId.Values) + { + if (bubble == null) continue; + Transform bubbleOffset = bubble.transform.GetChild(0); + Vector3 localPos = bubbleOffset.localPosition; + bubbleOffset.localPosition = new Vector3(localPos.x, targetHeight, localPos.z); + bubbleOffset.localScale = new Vector3(scaleModifier, scaleModifier, scaleModifier); + } + } + } + + private List GetOwnActiveBubbles() + { + string ownerId = MetaPort.Instance.ownerId; + return playerBubbles.TryGetValue(ownerId, out PlayerBubbleData playerData) + ? playerData.BubblesByLocalId.Values.ToList() + : new List(); + } + + #endregion +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/ShareBubbleRegistry.cs b/ShareBubbles/ShareBubbles/ShareBubbleRegistry.cs new file mode 100644 index 0000000..a0eb759 --- /dev/null +++ b/ShareBubbles/ShareBubbles/ShareBubbleRegistry.cs @@ -0,0 +1,32 @@ +using NAK.ShareBubbles.Impl; + +namespace NAK.ShareBubbles; + +public delegate IShareBubbleImpl BubbleImplFactory(); + +// This is all so fucked because I wanted to allow for custom bubble types, so Stickers could maybe be shared via ShareBubbles +// but it is aaaaaaaaaaaaaaaaaaaaaaa + +public static class ShareBubbleRegistry +{ + #region Type Registration + + private static readonly Dictionary registeredTypes = new(); + + public static void RegisterBubbleType(uint typeHash, BubbleImplFactory factory) + { + registeredTypes[typeHash] = factory; + } + + public static bool TryCreateImplementation(uint typeHash, out IShareBubbleImpl implementation) + { + implementation = null; + if (!registeredTypes.TryGetValue(typeHash, out BubbleImplFactory factory)) + return false; + + implementation = factory(); + return implementation != null; + } + + #endregion Type Registration +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/UI/BubbleAnimController.cs b/ShareBubbles/ShareBubbles/UI/BubbleAnimController.cs new file mode 100644 index 0000000..d51e880 --- /dev/null +++ b/ShareBubbles/ShareBubbles/UI/BubbleAnimController.cs @@ -0,0 +1,230 @@ +using UnityEngine; +using System.Collections; + +namespace NAK.ShareBubbles.UI +{ + public class BubbleAnimController : MonoBehaviour + { + [Header("Transform References")] + [SerializeField] private Transform centerPoint; + [SerializeField] private Transform hubPivot; + [SerializeField] private Transform base1; + [SerializeField] private Transform base2; + + [Header("Animation Settings")] + [SerializeField] private float spawnDuration = 1.2f; + [SerializeField] private float hubScaleDuration = 0.5f; + [SerializeField] private float centerHeightOffset = 0.2f; + [SerializeField] private AnimationCurve spawnBounceCurve; + [SerializeField] private AnimationCurve hubScaleCurve; + + [Header("Movement Settings")] + [SerializeField] private float positionSmoothTime = 0.1f; + [SerializeField] private float rotationSmoothTime = 0.15f; + [SerializeField] private float floatSpeed = 1f; + [SerializeField] private float floatHeight = 0.05f; + [SerializeField] private float baseRotationSpeed = 15f; + [SerializeField] private float spawnRotationSpeed = 180f; + + [Header("Inner Rotation Settings")] + [SerializeField] private float innerRotationSpeed = 0.8f; + [SerializeField] private float innerRotationRange = 10f; + [SerializeField] private AnimationCurve innerRotationGradient = AnimationCurve.Linear(0, 0.4f, 1, 1f); + + private SkinnedMeshRenderer base1Renderer; + private SkinnedMeshRenderer base2Renderer; + + private Transform[] base1Inners; + private Transform[] base2Inners; + private Vector3 centerTargetPos; + private Vector3 centerVelocity; + private float base1Rotation; + private float base2Rotation; + private float base1RotationSpeed; + private float base2RotationSpeed; + private float targetBase1RotationSpeed; + private float targetBase2RotationSpeed; + private float rotationSpeedVelocity1; + private float rotationSpeedVelocity2; + private float animationTime; + private bool isSpawning = true; + + private Quaternion[] base1InnerStartRots; + private Quaternion[] base2InnerStartRots; + + private void Start() + { + if (spawnBounceCurve.length == 0) + { + spawnBounceCurve = new AnimationCurve( + new Keyframe(0, 0, 0, 2), + new Keyframe(0.6f, 1.15f, 0, 0), + new Keyframe(0.8f, 0.95f, 0, 0), + new Keyframe(1, 1, 0, 0) + ); + } + + if (hubScaleCurve.length == 0) + { + hubScaleCurve = new AnimationCurve( + new Keyframe(0, 0, 0, 2), + new Keyframe(1, 1, 0, 0) + ); + } + + base1Inners = new Transform[3]; + base2Inners = new Transform[3]; + base1InnerStartRots = new Quaternion[3]; + base2InnerStartRots = new Quaternion[3]; + + Transform currentBase1 = base1; + Transform currentBase2 = base2; + for (int i = 0; i < 3; i++) + { + base1Inners[i] = currentBase1.GetChild(0); + base2Inners[i] = currentBase2.GetChild(0); + base1InnerStartRots[i] = base1Inners[i].localRotation; + base2InnerStartRots[i] = base2Inners[i].localRotation; + currentBase1 = base1Inners[i]; + currentBase2 = base2Inners[i]; + } + + base1RotationSpeed = spawnRotationSpeed; + base2RotationSpeed = -spawnRotationSpeed; + targetBase1RotationSpeed = spawnRotationSpeed; + targetBase2RotationSpeed = -spawnRotationSpeed; + + // hack + base1Renderer = base1.GetComponentInChildren(); + base2Renderer = base2.GetComponentInChildren(); + + ResetTransforms(); + StartCoroutine(SpawnAnimation()); + } + + private void ResetTransforms() + { + centerPoint.localScale = Vector3.zero; + centerPoint.localPosition = Vector3.zero; + hubPivot.localScale = Vector3.zero; + + base1.localScale = new Vector3(0.04f, 0.04f, 0.04f); + base1.localRotation = Quaternion.Euler(0, 180, 0); + + base2.localScale = new Vector3(0.02f, 0.02f, 0.02f); + base2.localRotation = Quaternion.Euler(0, 180, 0); + + centerTargetPos = Vector3.zero; + base1Rotation = 180f; + base2Rotation = 180f; + } + + private IEnumerator SpawnAnimation() + { + float elapsed = 0f; + + while (elapsed < spawnDuration) + { + float t = elapsed / spawnDuration; + float bounceT = spawnBounceCurve.Evaluate(t); + + // Center point and hub pivot animation with earlier start + float centerT = Mathf.Max(0, (t - 0.3f) * 1.43f); // Adjusted timing + centerPoint.localScale = Vector3.Lerp(Vector3.zero, Vector3.one, centerT); + centerTargetPos = Vector3.up * (bounceT * centerHeightOffset); + + // Base animations with inverted rotation + base1.localScale = Vector3.Lerp(new Vector3(0.04f, 0.04f, 0.04f), new Vector3(0.1f, 0.1f, 0.1f), bounceT); + base2.localScale = Vector3.Lerp(new Vector3(0.02f, 0.02f, 0.02f), new Vector3(0.06f, 0.06f, 0.06f), bounceT); + + base1Rotation += base1RotationSpeed * Time.deltaTime; + base2Rotation += base2RotationSpeed * Time.deltaTime; + + elapsed += Time.deltaTime; + yield return null; + } + + targetBase1RotationSpeed = baseRotationSpeed; + targetBase2RotationSpeed = -baseRotationSpeed; + isSpawning = false; + } + + public void ShowHubPivot() + { + StartCoroutine(ScaleHubPivot()); + } + + public void SetLifetimeVisual(float timeLeftNormalized) + { + float value = 100f - (timeLeftNormalized * 100f); + base1Renderer.SetBlendShapeWeight(0, value); + base2Renderer.SetBlendShapeWeight(0, value); + } + + private IEnumerator ScaleHubPivot() + { + float elapsed = 0f; + + while (elapsed < hubScaleDuration) + { + float t = elapsed / hubScaleDuration; + float scaleT = hubScaleCurve.Evaluate(t); + + hubPivot.localScale = Vector3.one * scaleT; + + elapsed += Time.deltaTime; + yield return null; + } + + hubPivot.localScale = Vector3.one; + } + + private void Update() + { + animationTime += Time.deltaTime; + + if (!isSpawning) + { + float floatOffset = Mathf.Sin(animationTime * floatSpeed) * floatHeight; + centerTargetPos = Vector3.up * (centerHeightOffset + floatOffset); + } + + centerPoint.localPosition = Vector3.SmoothDamp( + centerPoint.localPosition, + centerTargetPos, + ref centerVelocity, + positionSmoothTime + ); + + base1RotationSpeed = Mathf.SmoothDamp( + base1RotationSpeed, + targetBase1RotationSpeed, + ref rotationSpeedVelocity1, + rotationSmoothTime + ); + + base2RotationSpeed = Mathf.SmoothDamp( + base2RotationSpeed, + targetBase2RotationSpeed, + ref rotationSpeedVelocity2, + rotationSmoothTime + ); + + base1Rotation += base1RotationSpeed * Time.deltaTime; + base2Rotation += base2RotationSpeed * Time.deltaTime; + + base1.localRotation = Quaternion.Euler(0, base1Rotation, 0); + base2.localRotation = Quaternion.Euler(0, base2Rotation, 0); + + for (int i = 0; i < 3; i++) + { + float phase = (animationTime * innerRotationSpeed) * Mathf.Deg2Rad; + float gradientMultiplier = innerRotationGradient.Evaluate(i / 2f); + float rotationAmount = Mathf.Sin(phase) * innerRotationRange * gradientMultiplier; + + base1Inners[i].localRotation = base1InnerStartRots[i] * Quaternion.Euler(0, rotationAmount, 0); + base2Inners[i].localRotation = base2InnerStartRots[i] * Quaternion.Euler(0, -rotationAmount, 0); + } + } + } +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/UI/BubbleInteract.cs b/ShareBubbles/ShareBubbles/UI/BubbleInteract.cs new file mode 100644 index 0000000..ad2b4f4 --- /dev/null +++ b/ShareBubbles/ShareBubbles/UI/BubbleInteract.cs @@ -0,0 +1,51 @@ +using ABI_RC.Core.InteractionSystem; +using ABI_RC.Core.InteractionSystem.Base; +using ABI_RC.Core.Player; +using UnityEngine; + +namespace NAK.ShareBubbles.UI; + +// The script 'NAK.ShareBubbles.UI.BubbleInteract' could not be instantiated! +// Must be added manually by ShareBubble creation... +public class BubbleInteract : Interactable +{ + public override bool IsInteractableWithinRange(Vector3 sourcePos) + { + return Vector3.Distance(transform.position, sourcePos) < 1.5f; + } + + public override void OnInteractDown(InteractionContext context, ControllerRay controllerRay) + { + // Not used + } + + public override void OnInteractUp(InteractionContext context, ControllerRay controllerRay) + { + if (PlayerSetup.Instance.GetCurrentPropSelectionMode() + != PlayerSetup.PropSelectionMode.None) + return; + + // Check if the player is holding a pickup on the same hand + if (controllerRay.grabbedObject != null + && controllerRay.grabbedObject.transform == transform) + { + // Use the pickup + GetComponentInParent().EquipContent(); + controllerRay.DropObject(); // Causes null ref in ControllerRay, but doesn't break anything + return; + } + + // Open the details page + GetComponentInParent().ViewDetailsPage(); + } + + public override void OnHoverEnter(InteractionContext context, ControllerRay controllerRay) + { + // Not used + } + + public override void OnHoverExit(InteractionContext context, ControllerRay controllerRay) + { + // Not used + } +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/UI/FacePlayerCamDirection.cs b/ShareBubbles/ShareBubbles/UI/FacePlayerCamDirection.cs new file mode 100644 index 0000000..e2c181c --- /dev/null +++ b/ShareBubbles/ShareBubbles/UI/FacePlayerCamDirection.cs @@ -0,0 +1,21 @@ +using ABI_RC.Core.Player; +using UnityEngine; + +namespace NAK.ShareBubbles.UI; + +public class FacePlayerCamDirection : MonoBehaviour +{ + private const float lerpSpeed = 5.0f; + + private void LateUpdate() + { + Transform ourTransform = transform; + Vector3 playerCamPos = PlayerSetup.Instance.activeCam.transform.position; + Vector3 playerUp = PlayerSetup.Instance.transform.up; + + Vector3 direction = (playerCamPos - ourTransform.position).normalized; + + Quaternion targetRotation = Quaternion.LookRotation(direction, playerUp); + ourTransform.rotation = Quaternion.Slerp(ourTransform.rotation, targetRotation, Time.deltaTime * lerpSpeed); + } +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/UI/FacePlayerDirectionAxis.cs b/ShareBubbles/ShareBubbles/UI/FacePlayerDirectionAxis.cs new file mode 100644 index 0000000..78acf94 --- /dev/null +++ b/ShareBubbles/ShareBubbles/UI/FacePlayerDirectionAxis.cs @@ -0,0 +1,22 @@ +using ABI_RC.Core.Player; +using UnityEngine; + +namespace NAK.ShareBubbles.UI; + +public class FacePlayerDirectionAxis : MonoBehaviour +{ + private const float rotationSpeed = 5.0f; + + private void Update() + { + Transform ourTransform = transform; + + Vector3 ourUpDirection = ourTransform.up; + Vector3 playerDirection = PlayerSetup.Instance.GetPlayerPosition() - ourTransform.position; + + Vector3 projectedDirection = Vector3.ProjectOnPlane(playerDirection, ourUpDirection).normalized; + + Quaternion targetRotation = Quaternion.LookRotation(-projectedDirection, ourUpDirection); + ourTransform.rotation = Quaternion.Lerp(ourTransform.rotation, targetRotation, Time.deltaTime * rotationSpeed); + } +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/UI/HexagonSpinner.cs b/ShareBubbles/ShareBubbles/UI/HexagonSpinner.cs new file mode 100644 index 0000000..64c8499 --- /dev/null +++ b/ShareBubbles/ShareBubbles/UI/HexagonSpinner.cs @@ -0,0 +1,71 @@ +using UnityEngine; + +namespace NAK.ShareBubbles.UI +{ + public class HexagonSpinner : MonoBehaviour + { + [Tooltip("Base distance from the center point to each child")] + [SerializeField] private float radius = 1f; + + [Tooltip("How much the radius pulses in/out")] + [SerializeField] private float radiusPulseAmount = 0.2f; + + [Tooltip("Speed of the animation")] + [SerializeField] private float animationSpeed = 3f; + + private Transform[] children; + private Vector3[] hexagonPoints; + + private void Start() + { + children = new Transform[6]; + for (int i = 0; i < 6; i++) + { + if (i < transform.childCount) + { + children[i] = transform.GetChild(i); + } + else + { + Debug.LogError("HexagonSpinner requires exactly 6 child objects!"); + enabled = false; + return; + } + } + + // Calculate base hexagon points (XY plane, Z-up) + hexagonPoints = new Vector3[6]; + for (int i = 0; i < 6; i++) + { + float angle = i * 60f * Mathf.Deg2Rad; + hexagonPoints[i] = new Vector3( + Mathf.Sin(angle), + Mathf.Cos(angle), + 0f + ); + } + } + + private void Update() + { + for (int i = 0; i < 6; i++) + { + float phaseOffset = (i * 60f * Mathf.Deg2Rad); + float currentPhase = (Time.time * animationSpeed) + phaseOffset; + + // Calculate radius variation + float currentRadius = radius + (Mathf.Sin(currentPhase) * radiusPulseAmount); + + // Position each child + Vector3 basePosition = hexagonPoints[i] * currentRadius; + children[i].localPosition = basePosition; + + // Calculate scale based on sine wave, but only show points on the "visible" half + // Remap sine wave from [-1,1] to [0,1] for cleaner scaling + float scaleMultiplier = Mathf.Sin(currentPhase); + scaleMultiplier = Mathf.Max(0f, scaleMultiplier); // Only positive values + children[i].localScale = Vector3.one * scaleMultiplier; + } + } + } +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/UI/ReturnOnRelease.cs b/ShareBubbles/ShareBubbles/UI/ReturnOnRelease.cs new file mode 100644 index 0000000..b6e1eda --- /dev/null +++ b/ShareBubbles/ShareBubbles/UI/ReturnOnRelease.cs @@ -0,0 +1,91 @@ +using ABI_RC.Core.InteractionSystem; +using ABI_RC.Core.InteractionSystem.Base; +using UnityEngine; + +namespace NAK.ShareBubbles.UI; + +public class ReturnOnRelease : MonoBehaviour +{ + public float returnSpeed = 10.0f; + public float damping = 0.5f; + + private bool isReturning; + private Vector3 originalLocalPosition; + private Quaternion originalLocalRotation; + + private Vector3 positionVelocity = Vector3.zero; + private Vector3 angularVelocity = Vector3.zero; + + private Pickupable pickupable; + + private void Start() + { + Transform ourTransform = transform; + originalLocalPosition = ourTransform.localPosition; + originalLocalRotation = ourTransform.localRotation; + + // TODO: Instead of LateUpdate, use OnDrop to start a coroutine + pickupable = GetComponent(); + pickupable.onGrab.AddListener(OnPickupGrabbed); + pickupable.onDrop.AddListener(OnPickupRelease); + } + + public void OnPickupGrabbed(InteractionContext _) + { + isReturning = false; + } + + public void OnPickupRelease(InteractionContext _) + { + isReturning = true; + } + + private void LateUpdate() + { + if (isReturning) + { + // Smoothly damp position back to the original + Transform ourTransform = transform; + transform.localPosition = Vector3.SmoothDamp( + ourTransform.localPosition, + originalLocalPosition, + ref positionVelocity, + damping, + returnSpeed + ); + + // Smoothly damp rotation back to the original + transform.localRotation = SmoothDampQuaternion( + ourTransform.localRotation, + originalLocalRotation, + ref angularVelocity, + damping + ); + + // Stop returning when close enough + if (Vector3.Distance(transform.localPosition, originalLocalPosition) < 0.01f + && angularVelocity.magnitude < 0.01f) + { + isReturning = false; + transform.SetLocalPositionAndRotation(originalLocalPosition, originalLocalRotation); + } + } + } + + private Quaternion SmoothDampQuaternion(Quaternion current, Quaternion target, ref Vector3 velocity, float smoothTime) + { + // Decompose rotation to Euler angles for smoother interpolation + Vector3 currentEuler = current.eulerAngles; + Vector3 targetEuler = target.eulerAngles; + + // Perform SmoothDamp on each axis + Vector3 smoothedEuler = new Vector3( + Mathf.SmoothDampAngle(currentEuler.x, targetEuler.x, ref velocity.x, smoothTime), + Mathf.SmoothDampAngle(currentEuler.y, targetEuler.y, ref velocity.y, smoothTime), + Mathf.SmoothDampAngle(currentEuler.z, targetEuler.z, ref velocity.z, smoothTime) + ); + + // Convert back to Quaternion + return Quaternion.Euler(smoothedEuler); + } +} \ No newline at end of file diff --git a/ShareBubbles/ShareBubbles/UI/ShakeSoundController.cs b/ShareBubbles/ShareBubbles/UI/ShakeSoundController.cs new file mode 100644 index 0000000..1a0ed25 --- /dev/null +++ b/ShareBubbles/ShareBubbles/UI/ShakeSoundController.cs @@ -0,0 +1,85 @@ +using ABI_RC.Core; +using ABI_RC.Core.InteractionSystem.Base; +using UnityEngine; + +namespace NAK.ShareBubbles.UI; + +public class ShakeSoundController : MonoBehaviour +{ + [Header("Shake Detection")] + [SerializeField] private float minimumVelocityForShake = 5f; + [SerializeField] private float directionChangeThreshold = 0.8f; + [SerializeField] private float velocitySmoothing = 0.1f; + [SerializeField] private float minTimeBetweenShakes = 0.15f; + + [Header("Sound")] + [SerializeField] private AudioClip bellSound; + [SerializeField] [Range(0f, 1f)] private float minVolume = 0.8f; + [SerializeField] [Range(0f, 1f)] private float maxVolume = 1f; + [SerializeField] private float velocityToVolumeMultiplier = 0.1f; + + private AudioSource audioSource; + private Vector3 lastVelocity; + private Vector3 currentVelocity; + private Vector3 smoothedVelocity; + private float shakeTimer; + + private Pickupable _pickupable; + + private void Start() + { + audioSource = GetComponent(); + if (audioSource == null) audioSource = gameObject.AddComponent(); + audioSource.spatialize = true; + audioSource.spatialBlend = 1f; + audioSource.rolloffMode = AudioRolloffMode.Linear; + audioSource.maxDistance = 5f; + + _pickupable = GetComponent(); + + // Add source to prop mixer group so it can be muted + audioSource.outputAudioMixerGroup = RootLogic.Instance.propSfx; + + // TODO: Make jingle velocity scale with player playspace + // Make jingle velocity only sample local space, so OriginShift doesn't affect it + // Just make all this not bad... + } + + private void Update() + { + shakeTimer -= Time.deltaTime; + + if (!_pickupable.IsGrabbedByMe) + return; + + // Calculate raw velocity + currentVelocity = (transform.position - lastVelocity) / Time.deltaTime; + + // Smooth the velocity + smoothedVelocity = Vector3.Lerp(smoothedVelocity, currentVelocity, velocitySmoothing); + + // Only check for direction change if moving fast enough and cooldown has elapsed + if (smoothedVelocity.magnitude > minimumVelocityForShake && shakeTimer <= 0) + { + float directionChange = Vector3.Dot(smoothedVelocity.normalized, lastVelocity.normalized); + + if (directionChange < -directionChangeThreshold) + { + float shakePower = smoothedVelocity.magnitude * Mathf.Abs(directionChange); + PlayBellSound(shakePower); + shakeTimer = minTimeBetweenShakes; + } + } + + lastVelocity = transform.position; + } + + private void PlayBellSound(float intensity) + { + if (bellSound == null) return; + + float volume = Mathf.Clamp(intensity * velocityToVolumeMultiplier, minVolume, maxVolume); + audioSource.volume = volume; + audioSource.PlayOneShot(bellSound); + } +} \ No newline at end of file diff --git a/ShareBubbles/format.json b/ShareBubbles/format.json new file mode 100644 index 0000000..76dadf3 --- /dev/null +++ b/ShareBubbles/format.json @@ -0,0 +1,24 @@ +{ + "_id": -1, + "name": "ShareBubbles", + "modversion": "1.0.1", + "gameversion": "2024r177", + "loaderversion": "0.6.1", + "modtype": "Mod", + "author": "NotAKidoS, Exterrata, Noachi, RaidShadowLily, Tejler, Luc", + "description": "Share Bubbles! Allows you to drop down bubbles containing Avatars & Props. Requires both users to have the mod installed. Synced over Mod Network.\n### Features:\n- Can drop Share Bubbles linking to **any** Avatar or Prop page.\n - Share Bubbles can grant Permanent or Temporary shares to your **Private** Content if configured.\n- Adds basic in-game Share Management:\n - Directly share content to users within the current instance.\n - Revoke granted or received content shares.\n### Important:\nThe claiming of content shares through Share Bubbles will likely fail if you do not allow content shares from non-friends in your ABI Account settings!\n\n-# More information can be found on the [README](https://github.com/NotAKidoS/NAK_CVR_Mods/blob/main/ShareBubbles/README.md).", + "searchtags": [ + "share", + "bubbles", + "content", + "avatars", + "props" + ], + "requirements": [ + "None" + ], + "downloadlink": "https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r41/ShareBubbles.dll", + "sourcelink": "https://github.com/NotAKidoS/NAK_CVR_Mods/tree/main/ShareBubbles/", + "changelog": "- Initial release.", + "embedcolor": "#f61963" +} \ No newline at end of file