ShareBubbles: initial commit

This commit is contained in:
NotAKidoS 2024-12-02 20:06:26 -06:00
parent fe768029eb
commit 7b73452df6
39 changed files with 4901 additions and 0 deletions

View file

@ -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")
{
}
}

View file

@ -0,0 +1,112 @@
using ABI_RC.Core.Networking.API;
using NAK.ShareBubbles.API.Responses;
namespace NAK.ShareBubbles.API;
public enum PedestalType
{
Avatar,
Prop
}
/// <summary>
/// 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
/// </summary>
public static class PedestalInfoBatchProcessor
{
private static readonly Dictionary<PedestalType, Dictionary<string, TaskCompletionSource<PedestalInfoResponseButWithPublicationState>>> _pendingRequests
= new()
{
{ PedestalType.Avatar, new Dictionary<string, TaskCompletionSource<PedestalInfoResponseButWithPublicationState>>() },
{ PedestalType.Prop, new Dictionary<string, TaskCompletionSource<PedestalInfoResponseButWithPublicationState>>() }
};
private static readonly Dictionary<PedestalType, bool> _isBatchProcessing
= new()
{
{ PedestalType.Avatar, false },
{ PedestalType.Prop, false }
};
private static readonly object _lock = new();
private const float BATCH_DELAY = 2f;
public static Task<PedestalInfoResponseButWithPublicationState> QueuePedestalInfoRequest(PedestalType type, string contentId)
{
var tcs = new TaskCompletionSource<PedestalInfoResponseButWithPublicationState>();
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<string> contentIds;
Dictionary<string, TaskCompletionSource<PedestalInfoResponseButWithPublicationState>> requestBatch;
lock (_lock)
{
contentIds = _pendingRequests[type].Keys.ToList();
requestBatch = new Dictionary<string, TaskCompletionSource<PedestalInfoResponseButWithPublicationState>>(_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<IEnumerable<PedestalInfoResponseButWithPublicationState>>(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);
}
}
}

View file

@ -0,0 +1,22 @@
using Newtonsoft.Json;
namespace NAK.ShareBubbles.API.Responses;
public class ActiveSharesResponse
{
[JsonProperty("value")]
public List<ShareUser> 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; }
}

View file

@ -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; }
}

View file

@ -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;
/// <summary>
/// API for content sharing management.
/// </summary>
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
/// <summary>
/// Shares content with a specified user.
/// </summary>
/// <param name="type">Type of content to share</param>
/// <param name="contentId">ID of the content</param>
/// <param name="userId">Target user ID</param>
/// <returns>Response containing share information</returns>
/// <exception cref="ShareApiException">Thrown when API request fails</exception>
/// <exception cref="UserNotFoundException">Thrown when target user is not found</exception>
/// <exception cref="ContentNotFoundException">Thrown when content is not found</exception>
/// <exception cref="UserOnlyAllowsSharesFromFriendsException">Thrown when user only accepts shares from friends</exception>
/// <exception cref="ContentAlreadySharedException">Thrown when content is already shared with user</exception>
public static Task<BaseResponse<T>> ShareContentAsync<T>(ShareContentType type, string contentId, string userId)
{
ShareApiOperation operation = type == ShareContentType.Avatar
? ShareApiOperation.ShareAvatar
: ShareApiOperation.ShareSpawnable;
ShareRequest data = new()
{
ContentId = contentId,
UserId = userId
};
return MakeApiRequestAsync<T>(operation, data);
}
/// <summary>
/// Releases shared content from a specified user.
/// </summary>
/// <param name="type">Type of content to release</param>
/// <param name="contentId">ID of the content</param>
/// <param name="userId">Optional user ID. If null, releases share from self</param>
/// <returns>Response indicating success</returns>
/// <exception cref="ShareApiException">Thrown when API request fails</exception>
/// <exception cref="ContentNotSharedException">Thrown when content is not shared</exception>
/// <exception cref="ContentNotFoundException">Thrown when content is not found</exception>
/// <exception cref="UserNotFoundException">Thrown when specified user is not found</exception>
public static Task<BaseResponse<T>> ReleaseShareAsync<T>(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<T>(operation, data);
}
/// <summary>
/// Gets all shares for specified content.
/// </summary>
/// <param name="type">Type of content</param>
/// <param name="contentId">ID of the content</param>
/// <returns>Response containing share information</returns>
/// <exception cref="ShareApiException">Thrown when API request fails</exception>
/// <exception cref="ContentNotFoundException">Thrown when content is not found</exception>
public static Task<BaseResponse<T>> GetSharesAsync<T>(ShareContentType type, string contentId)
{
ShareApiOperation operation = type == ShareContentType.Avatar
? ShareApiOperation.GetAvatarShares
: ShareApiOperation.GetSpawnableShares;
ShareRequest data = new() { ContentId = contentId };
return MakeApiRequestAsync<T>(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<BaseResponse<T>> MakeApiRequestAsync<T>(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<T>(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<T> HandleApiResponse<T>(HttpResponseMessage response, string content, string contentId, string userId)
{
if (response.IsSuccessStatusCode)
return CreateSuccessResponse<T>(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<T> CreateSuccessResponse<T>(string content)
{
var response = new BaseResponse<T>("")
{
IsSuccessStatusCode = true,
HttpStatusCode = HttpStatusCode.OK
};
if (!string.IsNullOrEmpty(content))
{
response.Data = JsonConvert.DeserializeObject<T>(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
}

View file

@ -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
}

View file

@ -0,0 +1,80 @@
using ABI_RC.Systems.ModNetwork;
namespace NAK.ShareBubbles;
/// <summary>
/// Used to create a bubble. Need to define locally & over-network.
/// </summary>
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);
}
}
}

View file

@ -0,0 +1,8 @@
namespace NAK.ShareBubbles;
public enum ShareAccess
{
Permanent,
Session,
None
}

View file

@ -0,0 +1,7 @@
namespace NAK.ShareBubbles;
public enum ShareLifetime
{
Session,
TwoMinutes,
}

View file

@ -0,0 +1,7 @@
namespace NAK.ShareBubbles;
public enum ShareRule
{
Everyone,
FriendsOnly
}

View file

@ -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("<sprite=2> 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 = $"<sprite=2> {(details.IsPublic ? "Public" : "Private")} Avatar",
Icon = downloadedTexture
});
}
public void HandleClaimAccept(string userId, Action<bool> onClaimActionCompleted)
{
Task.Run(async () =>
{
try
{
var response = await ShareApiHelper.ShareContentAsync<BaseResponse>(
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;
}
}
}

View file

@ -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<bool> onClaimActionCompleted); // Handle the claim action (Share via API)
void Cleanup(); // Cleanup any resources
}

View file

@ -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("<sprite=0> 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 = $"<sprite=0> {(details.IsPublic ? "Public" : "Private")} Prop",
Icon = downloadedTexture
});
}
public void HandleClaimAccept(string userId, Action<bool> onClaimActionCompleted)
{
if (details == null)
{
onClaimActionCompleted(false);
return;
}
Task.Run(async () =>
{
try
{
var response = await ShareApiHelper.ShareContentAsync<BaseResponse>(
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;
}
}
}

View file

@ -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<string> 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<TempShare> 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<bool> RevokeShare(TempShare share)
{
try
{
var response = await ShareApiHelper.ReleaseShareAsync<BaseResponse>(
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<TempShareData>(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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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<ShareBubble> 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
}

View file

@ -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<BubbleAnimController>();
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 = "<sprite=3> 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
}

View file

@ -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<uint, ShareBubble> BubblesByLocalId = new();
}
private readonly Dictionary<string, PlayerBubbleData> 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<ShareBubble>();
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<ShareBubble> GetOwnActiveBubbles()
{
string ownerId = MetaPort.Instance.ownerId;
return playerBubbles.TryGetValue(ownerId, out PlayerBubbleData playerData)
? playerData.BubblesByLocalId.Values.ToList()
: new List<ShareBubble>();
}
#endregion
}

View file

@ -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<uint, BubbleImplFactory> 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
}

View file

@ -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<SkinnedMeshRenderer>();
base2Renderer = base2.GetComponentInChildren<SkinnedMeshRenderer>();
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);
}
}
}
}

View file

@ -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<ShareBubble>().EquipContent();
controllerRay.DropObject(); // Causes null ref in ControllerRay, but doesn't break anything
return;
}
// Open the details page
GetComponentInParent<ShareBubble>().ViewDetailsPage();
}
public override void OnHoverEnter(InteractionContext context, ControllerRay controllerRay)
{
// Not used
}
public override void OnHoverExit(InteractionContext context, ControllerRay controllerRay)
{
// Not used
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}
}
}

View file

@ -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>();
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);
}
}

View file

@ -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<AudioSource>();
if (audioSource == null) audioSource = gameObject.AddComponent<AudioSource>();
audioSource.spatialize = true;
audioSource.spatialBlend = 1f;
audioSource.rolloffMode = AudioRolloffMode.Linear;
audioSource.maxDistance = 5f;
_pickupable = GetComponent<Pickupable>();
// 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);
}
}