mirror of
https://github.com/NotAKidoS/NAK_CVR_Mods.git
synced 2025-09-04 07:19:22 +00:00
ShareBubbles: initial commit
This commit is contained in:
parent
fe768029eb
commit
7b73452df6
39 changed files with 4901 additions and 0 deletions
|
@ -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")
|
||||
{
|
||||
}
|
||||
}
|
112
ShareBubbles/ShareBubbles/API/PedestalInfoBatchProcessor.cs
Normal file
112
ShareBubbles/ShareBubbles/API/PedestalInfoBatchProcessor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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; }
|
||||
}
|
|
@ -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; }
|
||||
}
|
236
ShareBubbles/ShareBubbles/API/ShareApiHelper.cs
Normal file
236
ShareBubbles/ShareBubbles/API/ShareApiHelper.cs
Normal 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
|
||||
}
|
10
ShareBubbles/ShareBubbles/DataTypes/BubblePedestalInfo.cs
Normal file
10
ShareBubbles/ShareBubbles/DataTypes/BubblePedestalInfo.cs
Normal 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
|
||||
}
|
80
ShareBubbles/ShareBubbles/DataTypes/ShareBubbleData.cs
Normal file
80
ShareBubbles/ShareBubbles/DataTypes/ShareBubbleData.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
8
ShareBubbles/ShareBubbles/Enums/ShareAccess.cs
Normal file
8
ShareBubbles/ShareBubbles/Enums/ShareAccess.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
namespace NAK.ShareBubbles;
|
||||
|
||||
public enum ShareAccess
|
||||
{
|
||||
Permanent,
|
||||
Session,
|
||||
None
|
||||
}
|
7
ShareBubbles/ShareBubbles/Enums/ShareLifetime.cs
Normal file
7
ShareBubbles/ShareBubbles/Enums/ShareLifetime.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace NAK.ShareBubbles;
|
||||
|
||||
public enum ShareLifetime
|
||||
{
|
||||
Session,
|
||||
TwoMinutes,
|
||||
}
|
7
ShareBubbles/ShareBubbles/Enums/ShareRule.cs
Normal file
7
ShareBubbles/ShareBubbles/Enums/ShareRule.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace NAK.ShareBubbles;
|
||||
|
||||
public enum ShareRule
|
||||
{
|
||||
Everyone,
|
||||
FriendsOnly
|
||||
}
|
103
ShareBubbles/ShareBubbles/Implementation/AvatarBubbleImpl.cs
Normal file
103
ShareBubbles/ShareBubbles/Implementation/AvatarBubbleImpl.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
13
ShareBubbles/ShareBubbles/Implementation/IShareBubbleImpl.cs
Normal file
13
ShareBubbles/ShareBubbles/Implementation/IShareBubbleImpl.cs
Normal 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
|
||||
}
|
122
ShareBubbles/ShareBubbles/Implementation/SpawnableBubbleImpl.cs
Normal file
122
ShareBubbles/ShareBubbles/Implementation/SpawnableBubbleImpl.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
230
ShareBubbles/ShareBubbles/Implementation/TempShareManager.cs
Normal file
230
ShareBubbles/ShareBubbles/Implementation/TempShareManager.cs
Normal 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
|
||||
}
|
11
ShareBubbles/ShareBubbles/Networking/ModNetwork.Constants.cs
Normal file
11
ShareBubbles/ShareBubbles/Networking/ModNetwork.Constants.cs
Normal 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
|
||||
}
|
38
ShareBubbles/ShareBubbles/Networking/ModNetwork.Enums.cs
Normal file
38
ShareBubbles/ShareBubbles/Networking/ModNetwork.Enums.cs
Normal 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
|
||||
}
|
27
ShareBubbles/ShareBubbles/Networking/ModNetwork.Helpers.cs
Normal file
27
ShareBubbles/ShareBubbles/Networking/ModNetwork.Helpers.cs
Normal 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
|
||||
}
|
200
ShareBubbles/ShareBubbles/Networking/ModNetwork.Inbound.cs
Normal file
200
ShareBubbles/ShareBubbles/Networking/ModNetwork.Inbound.cs
Normal 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
|
||||
}
|
32
ShareBubbles/ShareBubbles/Networking/ModNetwork.Logging.cs
Normal file
32
ShareBubbles/ShareBubbles/Networking/ModNetwork.Logging.cs
Normal 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
|
||||
}
|
36
ShareBubbles/ShareBubbles/Networking/ModNetwork.Main.cs
Normal file
36
ShareBubbles/ShareBubbles/Networking/ModNetwork.Main.cs
Normal 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
|
||||
}
|
130
ShareBubbles/ShareBubbles/Networking/ModNetwork.Outbound.cs
Normal file
130
ShareBubbles/ShareBubbles/Networking/ModNetwork.Outbound.cs
Normal 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
|
||||
}
|
363
ShareBubbles/ShareBubbles/ShareBubble.cs
Normal file
363
ShareBubbles/ShareBubbles/ShareBubble.cs
Normal 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
|
||||
}
|
424
ShareBubbles/ShareBubbles/ShareBubbleManager.cs
Normal file
424
ShareBubbles/ShareBubbles/ShareBubbleManager.cs
Normal 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
|
||||
}
|
32
ShareBubbles/ShareBubbles/ShareBubbleRegistry.cs
Normal file
32
ShareBubbles/ShareBubbles/ShareBubbleRegistry.cs
Normal 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
|
||||
}
|
230
ShareBubbles/ShareBubbles/UI/BubbleAnimController.cs
Normal file
230
ShareBubbles/ShareBubbles/UI/BubbleAnimController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
51
ShareBubbles/ShareBubbles/UI/BubbleInteract.cs
Normal file
51
ShareBubbles/ShareBubbles/UI/BubbleInteract.cs
Normal 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
|
||||
}
|
||||
}
|
21
ShareBubbles/ShareBubbles/UI/FacePlayerCamDirection.cs
Normal file
21
ShareBubbles/ShareBubbles/UI/FacePlayerCamDirection.cs
Normal 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);
|
||||
}
|
||||
}
|
22
ShareBubbles/ShareBubbles/UI/FacePlayerDirectionAxis.cs
Normal file
22
ShareBubbles/ShareBubbles/UI/FacePlayerDirectionAxis.cs
Normal 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);
|
||||
}
|
||||
}
|
71
ShareBubbles/ShareBubbles/UI/HexagonSpinner.cs
Normal file
71
ShareBubbles/ShareBubbles/UI/HexagonSpinner.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
91
ShareBubbles/ShareBubbles/UI/ReturnOnRelease.cs
Normal file
91
ShareBubbles/ShareBubbles/UI/ReturnOnRelease.cs
Normal 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);
|
||||
}
|
||||
}
|
85
ShareBubbles/ShareBubbles/UI/ShakeSoundController.cs
Normal file
85
ShareBubbles/ShareBubbles/UI/ShakeSoundController.cs
Normal 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);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue