This commit is contained in:
NotAKidoS 2024-08-22 20:53:37 -05:00
parent 61f1a884d2
commit 475593bc1d
25 changed files with 2172 additions and 0 deletions

View file

@ -0,0 +1,414 @@
using ABI_RC.Core.Networking;
using ABI_RC.Systems.ModNetwork;
using DarkRift;
using NAK.Stickers.Properties;
using NAK.Stickers.Utilities;
using UnityEngine;
namespace NAK.Stickers.Networking
{
public static class ModNetwork
{
#region Configuration
private static bool Debug_NetworkInbound => ModSettings.Debug_NetworkInbound.Value;
private static bool Debug_NetworkOutbound => ModSettings.Debug_NetworkOutbound.Value;
#endregion Configuration
#region Constants
internal const int MaxTextureSize = 1024 * 256; // 256KB
private const string ModId = $"MelonMod.NAK.Stickers_v{AssemblyInfoParams.Version}";
private const int ChunkSize = 1024; // roughly 1KB per ModNetworkMessage
private const int MaxChunkCount = MaxTextureSize / ChunkSize;
#endregion Constants
#region Enums
private enum MessageType : byte
{
PlaceSticker = 0,
ClearStickers = 1,
StartTexture = 2,
SendTexture = 3,
EndTexture = 4,
RequestTexture = 5
}
#endregion Enums
#region Mod Network Internals
internal static Action<bool> OnTextureOutboundStateChanged;
internal static bool IsSendingTexture { get; private set; }
private static bool _isSubscribedToModNetwork;
private static byte[] _ourTextureData;
private static (Guid hashGuid, int Width, int Height) _ourTextureMetadata;
private static readonly Dictionary<string, byte[]> _textureChunkBuffers = new();
private static readonly Dictionary<string, int> _receivedChunkCounts = new();
private static readonly Dictionary<string, int> _expectedChunkCounts = new();
private static readonly Dictionary<string, (Guid Hash, int Width, int Height)> _textureMetadata = new();
internal static void Subscribe()
{
ModNetworkManager.Subscribe(ModId, OnMessageReceived);
_isSubscribedToModNetwork = ModNetworkManager.IsSubscribed(ModId);
if (!_isSubscribedToModNetwork)
StickerMod.Logger.Error("Failed to subscribe to Mod Network!");
}
public static void PlaceSticker(Vector3 position, Vector3 forward, Vector3 up)
{
if (!_isSubscribedToModNetwork)
return;
SendStickerPlace(_ourTextureMetadata.hashGuid, position, forward, up);
}
public static void ClearStickers()
{
if (!_isSubscribedToModNetwork)
return;
SendMessage(MessageType.ClearStickers);
}
public static bool SetTexture(byte[] imageBytes)
{
if (imageBytes == null
|| imageBytes.Length == 0
|| imageBytes.Length > MaxTextureSize)
return false;
(Guid hashGuid, int width, int height) = ImageUtility.ExtractImageInfo(imageBytes);
if (_ourTextureMetadata.hashGuid == hashGuid)
{
StickerMod.Logger.Msg($"[ModNetwork] Texture data is the same as the current texture: {hashGuid}");
return false;
}
_ourTextureData = imageBytes;
_ourTextureMetadata = (hashGuid, width, height);
StickerMod.Logger.Msg($"[ModNetwork] Set texture metadata for networking: {hashGuid} ({width}x{height})");
return true;
}
public static void SendTexture()
{
if (!IsConnectedToGameNetwork())
return;
if (!_isSubscribedToModNetwork || IsSendingTexture)
return;
if (_ourTextureData == null
|| _ourTextureMetadata.hashGuid == Guid.Empty)
return; // no texture to send
IsSendingTexture = true;
// Send each chunk of the texture data
Task.Run(() =>
{
try
{
if (Debug_NetworkOutbound)
StickerMod.Logger.Msg("[ModNetwork] Sending texture to network");
var textureData = _ourTextureData;
(Guid hash, int Width, int Height) textureMetadata = _ourTextureMetadata;
int totalChunks = Mathf.CeilToInt(textureData.Length / (float)ChunkSize);
if (totalChunks > MaxChunkCount)
{
StickerMod.Logger.Error($"[ModNetwork] Texture data too large to send: {textureData.Length} bytes, {totalChunks} chunks");
return;
}
if (Debug_NetworkOutbound)
StickerMod.Logger.Msg($"[ModNetwork] Texture data length: {textureData.Length}, total chunks: {totalChunks}, width: {textureMetadata.Width}, height: {textureMetadata.Height}");
SendStartTexture(textureMetadata.hash, totalChunks, textureMetadata.Width, textureMetadata.Height);
for (int i = 0; i < textureData.Length; i += ChunkSize)
{
int size = Mathf.Min(ChunkSize, textureData.Length - i);
byte[] chunk = new byte[size];
Array.Copy(textureData, i, chunk, 0, size);
SendTextureChunk(chunk, i / ChunkSize);
Thread.Sleep(5);
}
}
catch (Exception e)
{
if (Debug_NetworkOutbound) StickerMod.Logger.Error($"[ModNetwork] Failed to send texture to network: {e}");
}
finally
{
IsSendingTexture = false;
SendMessage(MessageType.EndTexture);
}
});
}
private static void SendStickerPlace(Guid textureHash, Vector3 position, Vector3 forward, Vector3 up)
{
if (!IsConnectedToGameNetwork())
return;
using ModNetworkMessage modMsg = new(ModId);
modMsg.Write((byte)MessageType.PlaceSticker);
modMsg.Write(textureHash);
modMsg.Write(position);
modMsg.Write(forward);
modMsg.Write(up);
modMsg.Send();
if (Debug_NetworkOutbound) StickerMod.Logger.Msg($"[Outbound] PlaceSticker: Hash: {textureHash}, Position: {position}, Forward: {forward}, Up: {up}");
}
private static void SendStartTexture(Guid hash, int chunkCount, int width, int height)
{
OnTextureOutboundStateChanged?.Invoke(true);
if (!IsConnectedToGameNetwork())
return;
using ModNetworkMessage modMsg = new(ModId);
modMsg.Write((byte)MessageType.StartTexture);
modMsg.Write(hash);
modMsg.Write(chunkCount);
modMsg.Write(width);
modMsg.Write(height);
modMsg.Send();
if (Debug_NetworkOutbound) StickerMod.Logger.Msg($"[Outbound] StartTexture sent with {chunkCount} chunks, width: {width}, height: {height}");
}
private static void SendTextureChunk(byte[] chunk, int chunkIdx)
{
if (!IsConnectedToGameNetwork())
return;
using ModNetworkMessage modMsg = new(ModId);
modMsg.Write((byte)MessageType.SendTexture);
modMsg.Write(chunkIdx);
modMsg.Write(chunk);
modMsg.Send();
if (Debug_NetworkOutbound) StickerMod.Logger.Msg($"[Outbound] Sent texture chunk {chunkIdx + 1}");
}
private static void SendMessage(MessageType messageType)
{
OnTextureOutboundStateChanged?.Invoke(false);
if (!IsConnectedToGameNetwork())
return;
using ModNetworkMessage modMsg = new(ModId);
modMsg.Write((byte)messageType);
modMsg.Send();
if (Debug_NetworkOutbound) StickerMod.Logger.Msg($"[Outbound] MessageType: {messageType}");
}
private static void SendTextureRequest(string sender)
{
if (!IsConnectedToGameNetwork())
return;
using ModNetworkMessage modMsg = new(ModId);
modMsg.Write((byte)MessageType.RequestTexture);
modMsg.Write(sender);
modMsg.Send();
if (Debug_NetworkOutbound) StickerMod.Logger.Msg($"[Outbound] RequestTexture sent to {sender}");
}
private static void OnMessageReceived(ModNetworkMessage msg)
{
msg.Read(out byte msgTypeRaw);
if (!Enum.IsDefined(typeof(MessageType), msgTypeRaw))
return;
if (Debug_NetworkInbound)
StickerMod.Logger.Msg($"[Inbound] Sender: {msg.Sender}, MessageType: {(MessageType)msgTypeRaw}");
switch ((MessageType)msgTypeRaw)
{
case MessageType.PlaceSticker:
msg.Read(out Guid hash);
msg.Read(out Vector3 receivedPosition);
msg.Read(out Vector3 receivedForward);
msg.Read(out Vector3 receivedUp);
OnStickerPlaceReceived(msg.Sender, hash, receivedPosition, receivedForward, receivedUp);
break;
case MessageType.ClearStickers:
OnStickersClearReceived(msg.Sender);
break;
case MessageType.StartTexture:
msg.Read(out Guid startHash);
msg.Read(out int chunkCount);
msg.Read(out int width);
msg.Read(out int height);
OnStartTextureReceived(msg.Sender, startHash, chunkCount, width, height);
break;
case MessageType.SendTexture:
msg.Read(out int chunkIdx);
msg.Read(out byte[] textureChunk);
OnTextureChunkReceived(msg.Sender, textureChunk, chunkIdx);
break;
case MessageType.EndTexture:
OnEndTextureReceived(msg.Sender);
break;
case MessageType.RequestTexture:
OnTextureRequestReceived(msg.Sender);
break;
default:
if (Debug_NetworkInbound) StickerMod.Logger.Error($"[ModNetwork] Invalid message type received from: {msg.Sender}");
break;
}
}
#endregion Mod Network Internals
#region Private Methods
private static bool IsConnectedToGameNetwork()
{
return NetworkManager.Instance != null
&& NetworkManager.Instance.GameNetwork != null
&& NetworkManager.Instance.GameNetwork.ConnectionState == ConnectionState.Connected;
}
private static void OnStickerPlaceReceived(string sender, Guid textureHash, Vector3 position, Vector3 forward, Vector3 up)
{
Guid localHash = StickerSystem.Instance.GetPlayerStickerTextureHash(sender);
if (localHash != textureHash && textureHash != Guid.Empty) SendTextureRequest(sender);
StickerSystem.Instance.OnPlayerStickerPlace(sender, position, forward, up);
}
private static void OnStickersClearReceived(string sender)
{
StickerSystem.Instance.OnPlayerStickersClear(sender);
}
private static void OnStartTextureReceived(string sender, Guid hash, int chunkCount, int width, int height)
{
if (_textureChunkBuffers.ContainsKey(sender))
{
if (Debug_NetworkInbound) StickerMod.Logger.Warning($"[Inbound] Received StartTexture message from {sender} while still receiving texture data!");
return;
}
Guid oldHash = StickerSystem.Instance.GetPlayerStickerTextureHash(sender);
if (oldHash == hash)
{
if (Debug_NetworkInbound)
StickerMod.Logger.Msg($"[Inbound] Received StartTexture message from {sender} with existing texture hash {hash}, skipping texture data.");
return;
}
if (chunkCount > MaxChunkCount)
{
StickerMod.Logger.Error($"[Inbound] Received StartTexture message from {sender} with too many chunks: {chunkCount}");
return;
}
_textureChunkBuffers[sender] = new byte[Mathf.Clamp(chunkCount * ChunkSize, 0, MaxTextureSize)];
_receivedChunkCounts[sender] = 0;
_expectedChunkCounts[sender] = chunkCount;
_textureMetadata[sender] = (hash, width, height);
if (Debug_NetworkInbound)
StickerMod.Logger.Msg($"[Inbound] StartTexture received from {sender} with hash: {hash} chunk count: {chunkCount}, width: {width}, height: {height}");
}
private static void OnTextureChunkReceived(string sender, byte[] chunk, int chunkIdx)
{
if (!_textureChunkBuffers.TryGetValue(sender, out var buffer))
return;
int startIndex = chunkIdx * ChunkSize;
Array.Copy(chunk, 0, buffer, startIndex, chunk.Length);
_receivedChunkCounts[sender]++;
if (_receivedChunkCounts[sender] < _expectedChunkCounts[sender])
return;
(Guid Hash, int Width, int Height) metadata = _textureMetadata[sender];
// All chunks received, reassemble texture
_textureChunkBuffers.Remove(sender);
_receivedChunkCounts.Remove(sender);
_expectedChunkCounts.Remove(sender);
_textureMetadata.Remove(sender);
// Validate image
if (!ImageUtility.IsValidImage(buffer))
{
StickerMod.Logger.Error($"[Inbound] Received texture data is not a valid image from {sender}!");
return;
}
// Validate data TODO: fix hash???????
(Guid imageHash, int width, int height) = ImageUtility.ExtractImageInfo(buffer);
if (metadata.Width != width
|| metadata.Height != height)
{
StickerMod.Logger.Error($"[Inbound] Received texture data does not match metadata! Expected: {metadata.Hash} ({metadata.Width}x{metadata.Height}), received: {imageHash} ({width}x{height})");
return;
}
Texture2D texture = new(1,1);
texture.LoadImage(buffer);
texture.Compress(true);
StickerSystem.Instance.OnPlayerStickerTextureReceived(sender, metadata.Hash, texture);
if (Debug_NetworkInbound) StickerMod.Logger.Msg($"[Inbound] All chunks received and texture reassembled from {sender}. " +
$"Texture size: {metadata.Width}x{metadata.Height}");
}
private static void OnEndTextureReceived(string sender)
{
if (!_textureChunkBuffers.ContainsKey(sender))
return;
if (Debug_NetworkInbound) StickerMod.Logger.Error($"[Inbound] Received EndTexture message without all chunks received from {sender}! Only {_receivedChunkCounts[sender]} out of {_expectedChunkCounts[sender]} received.");
_textureChunkBuffers.Remove(sender);
_receivedChunkCounts.Remove(sender);
_expectedChunkCounts.Remove(sender);
_textureMetadata.Remove(sender);
}
private static void OnTextureRequestReceived(string sender)
{
if (!_isSubscribedToModNetwork || IsSendingTexture)
return;
if (_ourTextureData != null && _ourTextureMetadata.hashGuid != Guid.Empty)
SendTexture();
else
StickerMod.Logger.Warning($"[Inbound] Received texture request from {sender}, but no texture is set!");
}
#endregion Private Methods
}
}

View file

@ -0,0 +1,132 @@
using ABI_RC.Core;
using UnityEngine;
using Object = UnityEngine.Object;
namespace NAK.Stickers;
public class StickerData
{
private const float DECAL_SIZE = 0.25f;
public float DeathTime; // when a remote player leaves, we need to kill their stickers
public Guid TextureHash;
public float LastPlacedTime;
private Vector3 _lastPlacedPosition = Vector3.zero;
public readonly bool IsLocal;
private readonly DecalType _decal;
private readonly DecalSpawner _spawner;
private readonly AudioSource _audioSource;
public StickerData(bool isLocal)
{
IsLocal = isLocal;
_decal = ScriptableObject.CreateInstance<DecalType>();
_decal.decalSettings = new DecalSpawner.InitData
{
material = new Material(StickerMod.DecalSimpleShader)
{
//color = new Color(Random.value, Random.value, Random.value, 1f),
},
useShaderReplacement = false,
inheritMaterialProperties = false,
inheritMaterialPropertyBlock = false,
};
_spawner = DecalManager.GetSpawner(_decal.decalSettings, 4096, 1024);
_audioSource = new GameObject("StickerAudioSource").AddComponent<AudioSource>();
_audioSource.spatialBlend = 1f;
_audioSource.volume = 0.5f;
_audioSource.playOnAwake = false;
_audioSource.loop = false;
_audioSource.rolloffMode = AudioRolloffMode.Logarithmic;
_audioSource.maxDistance = 5f;
_audioSource.minDistance = 1f;
_audioSource.outputAudioMixerGroup = RootLogic.Instance.propSfx; // props are close enough to stickers
if (isLocal) Object.DontDestroyOnLoad(_audioSource.gameObject); // keep audio source through world transitions
}
public void SetTexture(Guid textureHash, Texture2D texture)
{
if (texture == null) StickerMod.Logger.Warning("Assigning null texture to StickerData!");
TextureHash = textureHash;
texture.wrapMode = TextureWrapMode.Clamp; // noachi said to do, prevents white edges
texture.filterMode = texture.width > 64 || texture.height > 64
? FilterMode.Bilinear // smear it cause its fat
: FilterMode.Point; // my minecraft skin looked shit
if (IsLocal) StickerMod.Logger.Msg($"Set texture filter mode to: {texture.filterMode}");
Material material = _decal.decalSettings.material;
// TODO: fix
if (material.mainTexture != null) Object.Destroy(material.mainTexture);
material.mainTexture = texture;
}
public void Place(RaycastHit hit, Vector3 forwardDirection, Vector3 upDirection)
{
Transform rootObject = null;
if (hit.rigidbody != null) rootObject = hit.rigidbody.transform;
_lastPlacedPosition = hit.point;
LastPlacedTime = Time.time;
// todo: add decal to queue
_spawner.AddDecal(
_lastPlacedPosition, Quaternion.LookRotation(forwardDirection, upDirection),
hit.collider.gameObject,
DECAL_SIZE, DECAL_SIZE, 1f, 1f, 0f, rootObject);
}
public void Clear()
{
// BUG?: Release does not clear dictionary's, clearing them myself so we can hit same objects again
// maybe it was intended to recycle groups- but rn no work like that
_spawner.Release();
_spawner.staticGroups.Clear();
_spawner.movableGroups.Clear();
}
public void Cleanup()
{
Clear();
// no leaking textures or mats
Material material = _decal.decalSettings.material;
Object.Destroy(material.mainTexture);
Object.Destroy(material);
Object.Destroy(_decal);
}
public void PlayAudio()
{
_audioSource.transform.position = _lastPlacedPosition;
switch (ModSettings.Entry_SelectedSFX.Value)
{
case ModSettings.SFXType.Source:
_audioSource.PlayOneShot(StickerMod.SourceSFXPlayerSprayer);
break;
case ModSettings.SFXType.LBP:
_audioSource.PlayOneShot(StickerMod.LittleBigPlanetStickerPlace);
break;
case ModSettings.SFXType.None:
break;
default:
throw new ArgumentOutOfRangeException();
}
}
public void SetAlpha(float alpha)
{
Material material = _decal.decalSettings.material;
material.color = new Color(material.color.r, material.color.g, material.color.b, alpha);
}
}

View file

@ -0,0 +1,406 @@
using System.Diagnostics;
using ABI_RC.Core.IO;
using ABI_RC.Core.Player;
using ABI_RC.Core.Savior;
using ABI_RC.Core.UI;
using ABI_RC.Systems.GameEventSystem;
using MTJobSystem;
using NAK.Stickers.Integrations;
using NAK.Stickers.Networking;
using NAK.Stickers.Utilities;
using UnityEngine;
namespace NAK.Stickers;
public class StickerSystem
{
#region Singleton
public static StickerSystem Instance { get; private set; }
public static void Initialize()
{
if (Instance != null)
return;
Instance = new StickerSystem();
// ensure cache folder exists
if (!Directory.Exists(s_StickersSourcePath)) Directory.CreateDirectory(s_StickersSourcePath);
// configure Decalery
ModSettings.DecaleryMode selectedMode = ModSettings.Decalery_DecalMode.Value;
DecalManager.SetPreferredMode((DecalUtils.Mode)selectedMode, selectedMode == ModSettings.DecaleryMode.GPUIndirect, 0);
if (selectedMode != ModSettings.DecaleryMode.GPU) StickerMod.Logger.Warning("Decalery is not set to GPU mode. Expect compatibility issues with user generated content when mesh data is not marked as readable.");
// listen for game events
CVRGameEventSystem.Initialization.OnPlayerSetupStart.AddListener(Instance.OnPlayerSetupStart);
}
#endregion Singleton
#region Actions
public static Action<string> OnImageLoadFailed;
private void InvokeOnImageLoadFailed(string errorMessage)
{
MTJobManager.RunOnMainThread("StickersSystem.InvokeOnImageLoadFailed", () => OnImageLoadFailed?.Invoke(errorMessage));
}
#endregion Actions
#region Data
private bool _isInStickerMode;
public bool IsInStickerMode
{
get => _isInStickerMode;
set
{
_isInStickerMode = value;
if (_isInStickerMode) CohtmlHud.Instance.SelectPropToSpawn(BtkUiAddon.GetBtkUiCachePath(ModSettings.Hidden_SelectedStickerName.Value), ModSettings.Hidden_SelectedStickerName.Value, "Sticker selected for stickering:");
else CohtmlHud.Instance.ClearPropToSpawn();
}
}
private const float StickerKillTime = 30f;
private const float StickerCooldown = 0.2f;
private readonly Dictionary<string, StickerData> _playerStickers = new();
private const string PlayerLocalId = "_PLAYERLOCAL";
private readonly List<StickerData> _deadStickerPool = new(); // for cleanup on player leave
#endregion Data
#region Game Events
private void OnPlayerSetupStart()
{
CVRGameEventSystem.World.OnUnload.AddListener(_ => Instance.CleanupAllButSelf());
CVRGameEventSystem.Player.OnJoinEntity.AddListener(Instance.OnPlayerJoined);
CVRGameEventSystem.Player.OnLeaveEntity.AddListener(Instance.OnPlayerLeft);
SchedulerSystem.AddJob(Instance.OnOccasionalUpdate, 10f, 1f);
LoadImage(ModSettings.Hidden_SelectedStickerName.Value);
}
private void OnPlayerJoined(CVRPlayerEntity playerEntity)
{
if (!_playerStickers.TryGetValue(playerEntity.Uuid, out StickerData stickerData))
return;
stickerData.DeathTime = -1f;
stickerData.SetAlpha(1f);
_deadStickerPool.Remove(stickerData);
}
private void OnPlayerLeft(CVRPlayerEntity playerEntity)
{
if (!_playerStickers.TryGetValue(playerEntity.Uuid, out StickerData stickerData))
return;
stickerData.DeathTime = Time.time + StickerKillTime;
stickerData.SetAlpha(1f);
_deadStickerPool.Add(stickerData);
}
private void OnOccasionalUpdate()
{
if (_deadStickerPool.Count == 0)
return;
for (var i = _deadStickerPool.Count - 1; i >= 0; i--)
{
float currentTime = Time.time;
StickerData stickerData = _deadStickerPool[i];
if (stickerData.DeathTime < 0f)
continue;
if (currentTime < stickerData.DeathTime)
{
stickerData.SetAlpha(Mathf.Lerp(0f, 1f, (stickerData.DeathTime - currentTime) / StickerKillTime));
continue;
}
_playerStickers.Remove(_playerStickers.First(x => x.Value == stickerData).Key);
_deadStickerPool.RemoveAt(i);
stickerData.Cleanup();
}
}
#endregion Game Events
#region Local Player
public void PlaceStickerFromTransform(Transform transform)
{
Vector3 controllerForward = transform.forward;
Vector3 controllerUp = transform.up;
Vector3 playerUp = PlayerSetup.Instance.transform.up;
// extracting angle of controller ray on forward axis
Vector3 projectedControllerUp = Vector3.ProjectOnPlane(controllerUp, controllerForward).normalized;
Vector3 projectedPlayerUp = Vector3.ProjectOnPlane(playerUp, controllerForward).normalized;
float angle = Vector3.Angle(projectedControllerUp, projectedPlayerUp);
float angleThreshold = ModSettings.Entry_PlayerUpAlignmentThreshold.Value;
Vector3 targetUp = (angleThreshold != 0f && angle <= angleThreshold)
// leave 0.01% of the controller up vector to prevent issues with alignment on floor & ceiling in Desktop
? Vector3.Slerp(controllerUp, playerUp, 0.99f)
: controllerUp;
PlaceStickerSelf(transform.position, transform.forward, targetUp);
}
/// Place own sticker. Network if successful.
private void PlaceStickerSelf(Vector3 position, Vector3 forward, Vector3 up, bool alignWithNormal = true)
{
if (!AttemptPlaceSticker(PlayerLocalId, position, forward, up, alignWithNormal))
return; // failed
// placed, now network
ModNetwork.PlaceSticker(position, forward, up);
}
/// Clear own stickers. Network if successful.
public void ClearStickersSelf()
{
OnPlayerStickersClear(PlayerLocalId);
ModNetwork.ClearStickers();
}
/// Set own Texture2D for sticker. Network if cusses.
private void SetTextureSelf(byte[] imageBytes)
{
Texture2D texture = new(1, 1); // placeholder
texture.LoadImage(imageBytes);
texture.Compress(true); // noachi said to do
OnPlayerStickerTextureReceived(PlayerLocalId, Guid.Empty, texture);
if (ModNetwork.SetTexture(imageBytes)) ModNetwork.SendTexture();
}
#endregion Local Player
#region Public Methods
/// When a player wants to place a sticker.
public void OnPlayerStickerPlace(string playerId, Vector3 position, Vector3 forward, Vector3 up)
=> AttemptPlaceSticker(playerId, position, forward, up);
/// When a player wants to clear their stickers.
public void OnPlayerStickersClear(string playerId)
=> ClearStickersForPlayer(playerId);
/// Clear all stickers from all players.
public void ClearAllStickers()
{
foreach (StickerData stickerData in _playerStickers.Values)
stickerData.Clear();
ModNetwork.ClearStickers();
}
public void OnPlayerStickerTextureReceived(string playerId, Guid textureHash, Texture2D texture)
{
StickerData stickerData = GetOrCreateStickerData(playerId);
stickerData.SetTexture(textureHash, texture);
}
public Guid GetPlayerStickerTextureHash(string playerId)
{
StickerData stickerData = GetOrCreateStickerData(playerId);
return stickerData.TextureHash;
}
public void CleanupAll()
{
foreach ((_, StickerData data) in _playerStickers)
data.Cleanup();
_playerStickers.Clear();
}
public void CleanupAllButSelf()
{
StickerData localStickerData = GetOrCreateStickerData(PlayerLocalId);
foreach ((_, StickerData data) in _playerStickers)
{
if (data.IsLocal) data.Clear();
else data.Cleanup();
}
_playerStickers.Clear();
_playerStickers[PlayerLocalId] = localStickerData;
}
#endregion Public Methods
#region Private Methods
private StickerData GetOrCreateStickerData(string playerId)
{
if (_playerStickers.TryGetValue(playerId, out StickerData stickerData))
return stickerData;
stickerData = new StickerData(playerId == PlayerLocalId);
_playerStickers[playerId] = stickerData;
return stickerData;
}
private bool AttemptPlaceSticker(string playerId, Vector3 position, Vector3 forward, Vector3 up, bool alignWithNormal = true)
{
StickerData stickerData = GetOrCreateStickerData(playerId);
if (Time.time - stickerData.LastPlacedTime < StickerCooldown)
return false;
// 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 false;
stickerData.Place(hit, alignWithNormal ? -hit.normal : forward, up);
stickerData.PlayAudio();
return true;
}
private void ClearStickersForPlayer(string playerId)
{
if (!_playerStickers.TryGetValue(playerId, out StickerData stickerData))
return;
stickerData.Clear();
}
private void ReleaseStickerData(string playerId)
{
if (!_playerStickers.TryGetValue(playerId, out StickerData stickerData))
return;
stickerData.Cleanup();
_playerStickers.Remove(playerId);
}
#endregion Private Methods
#region Image Loading
private static readonly string s_StickersSourcePath = Application.dataPath + "/../UserData/Stickers/";
private bool _isLoadingImage;
public void LoadImage(string imageName)
{
if (string.IsNullOrEmpty(imageName))
return;
if (_isLoadingImage) return;
_isLoadingImage = true;
Task.Run(() =>
{
try
{
if (!TryLoadImage(imageName, out string errorMessage))
{
InvokeOnImageLoadFailed(errorMessage);
}
}
catch (Exception ex)
{
InvokeOnImageLoadFailed(ex.Message);
}
finally
{
_isLoadingImage = false;
}
});
}
private bool TryLoadImage(string imageName, out string errorMessage)
{
errorMessage = string.Empty;
if (ModNetwork.IsSendingTexture)
{
StickerMod.Logger.Warning("A texture is currently being sent over the network. Cannot load a new image yet.");
errorMessage = "A texture is currently being sent over the network. Cannot load a new image yet.";
return false;
}
if (!Directory.Exists(s_StickersSourcePath)) Directory.CreateDirectory(s_StickersSourcePath);
string imagePath = Path.Combine(s_StickersSourcePath, imageName + ".png");
FileInfo fileInfo = new(imagePath);
if (!fileInfo.Exists)
{
StickerMod.Logger.Warning($"Target image does not exist on disk. Path: {imagePath}");
errorMessage = "Target image does not exist on disk.";
return false;
}
var bytes = File.ReadAllBytes(imagePath);
if (!ImageUtility.IsValidImage(bytes))
{
StickerMod.Logger.Error("File is not a valid image or is corrupt.");
errorMessage = "File is not a valid image or is corrupt.";
return false;
}
StickerMod.Logger.Msg("Loaded image from disk. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)");
if (bytes.Length > ModNetwork.MaxTextureSize)
{
ImageUtility.Resize(ref bytes, 256, 256);
StickerMod.Logger.Warning("File ate too many cheeseburgers. Attempting experimental resize. Notice: this may cause filesize to increase.");
StickerMod.Logger.Msg("Resized image. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)");
}
if (ImageUtility.ResizeToNearestPowerOfTwo(ref bytes))
{
StickerMod.Logger.Warning("Image resolution was not a power of two. Attempting experimental resize. Notice: this may cause filesize to increase.");
StickerMod.Logger.Msg("Resized image. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)");
}
if (bytes.Length > ModNetwork.MaxTextureSize)
{
StickerMod.Logger.Error("File is still too large. Aborting. Size in KB: " + bytes.Length / 1024 + " (" + bytes.Length + " bytes)");
StickerMod.Logger.Msg("Please resize the image manually to be smaller than " + ModNetwork.MaxTextureSize / 1024 + " KB and round resolution to nearest power of two.");
errorMessage = "File is still too large. Please resize the image manually to be smaller than " + ModNetwork.MaxTextureSize / 1024 + " KB and round resolution to nearest power of two.";
return false;
}
StickerMod.Logger.Msg("Image successfully loaded.");
MTJobManager.RunOnMainThread("StickersSystem.LoadImage", () =>
{
ModSettings.Hidden_SelectedStickerName.Value = imageName;
SetTextureSelf(bytes);
if (!IsInStickerMode) return;
IsInStickerMode = false;
IsInStickerMode = true;
});
return true;
}
public static void OpenStickersFolder()
{
if (!Directory.Exists(s_StickersSourcePath)) Directory.CreateDirectory(s_StickersSourcePath);
Process.Start(s_StickersSourcePath);
}
public static string GetStickersFolderPath()
{
if (!Directory.Exists(s_StickersSourcePath)) Directory.CreateDirectory(s_StickersSourcePath);
return s_StickersSourcePath;
}
#endregion Image Loading
}

View file

@ -0,0 +1,171 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Security.Cryptography;
namespace NAK.Stickers.Utilities;
// i do not know shit about images
// TODO: optimize Image usage, attempt create, reuse when valid
public static class ImageUtility
{
public static bool IsValidImage(byte[] image)
{
try
{
using MemoryStream stream = new(image);
using Image img = Image.FromStream(stream);
return img.Width > 0 && img.Height > 0;
}
catch (Exception e)
{
StickerMod.Logger.Error($"[ImageUtility] Failed to validate image: {e}");
return false;
}
}
public static void Resize(ref byte[] image, int width, int height)
{
MemoryStream stream = new(image);
Resize(ref stream, width, height);
image = stream.ToArray();
stream.Dispose();
}
public static void Resize(ref MemoryStream stream, int width, int height)
{
using Image source = Image.FromStream(stream);
using Bitmap bitmap = new(width, height);
Rectangle destRect = new(0, 0, width, height);
using (Graphics graphics = Graphics.FromImage(bitmap))
{
graphics.CompositingMode = CompositingMode.SourceCopy;
graphics.CompositingQuality = CompositingQuality.HighQuality;
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
graphics.SmoothingMode = SmoothingMode.HighQuality;
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
using (ImageAttributes wrapMode = new())
{
wrapMode.SetWrapMode(WrapMode.TileFlipXY);
graphics.DrawImage(source, destRect, 0, 0, source.Width, source.Height, GraphicsUnit.Pixel, wrapMode);
}
}
stream.Dispose();
stream = new MemoryStream();
SaveBitmapPreservingFormat(bitmap, stream);
}
private static void SaveBitmapPreservingFormat(Bitmap bitmap, Stream stream)
{
bool hasTransparency = ImageHasTransparency(bitmap);
if (!hasTransparency)
{
ImageCodecInfo jpegEncoder = ImageCodecInfo.GetImageEncoders().First(x => x.FormatID == ImageFormat.Jpeg.Guid);
EncoderParameters jpegEncoderParameters = new(1)
{
Param = new[]
{
new EncoderParameter(Encoder.Quality, 80L) // basically, fuck you, get smaller
}
};
bitmap.Save(stream, jpegEncoder, jpegEncoderParameters);
}
else
{
ImageCodecInfo pngEncoder = ImageCodecInfo.GetImageEncoders().First(x => x.FormatID == ImageFormat.Png.Guid);
EncoderParameters pngEncoderParameters = new(1)
{
Param = new[]
{
//new EncoderParameter(Encoder.Quality, 50L),
new EncoderParameter(Encoder.Compression, (long)EncoderValue.CompressionLZW), // PNG compression
new EncoderParameter(Encoder.ColorDepth, bitmap.PixelFormat == PixelFormat.Format32bppArgb ? 32L : 24L),
new EncoderParameter(Encoder.ScanMethod, (long)EncoderValue.ScanMethodInterlaced),
}
};
bitmap.Save(stream, pngEncoder, pngEncoderParameters);
}
}
private static bool ImageHasTransparency(Bitmap bitmap)
{
for (int y = 0; y < bitmap.Height; y++)
for (int x = 0; x < bitmap.Width; x++)
if (bitmap.GetPixel(x, y).A < 255)
return true;
return false; // no transparency found
}
public static bool IsPowerOfTwo(byte[] image)
{
using MemoryStream stream = new(image);
using Image source = Image.FromStream(stream);
return IsPowerOfTwo(source.Width) && IsPowerOfTwo(source.Height);
}
private static bool IsPowerOfTwo(int value)
{
return (value > 0) && (value & (value - 1)) == 0;
}
public static int NearestPowerOfTwo(int value)
{
return (int)Math.Pow(2, Math.Ceiling(Math.Log(value, 2)));
}
public static int FlooredPowerOfTwo(int value)
{
return (int)Math.Pow(2, Math.Floor(Math.Log(value, 2)));
}
public static bool ResizeToNearestPowerOfTwo(ref byte[] image)
{
using MemoryStream stream = new(image);
using Image source = Image.FromStream(stream);
if (IsPowerOfTwo(source.Width) && IsPowerOfTwo(source.Height))
return false; // already power of two
int newWidth = NearestPowerOfTwo(source.Width);
int newHeight = NearestPowerOfTwo(source.Height);
Resize(ref image, newWidth, newHeight);
return true;
}
// making the assumption that the above could potentially put an image over my filesize limit
public static bool ResizeToFlooredPowerOfTwo(byte[] image)
{
using MemoryStream stream = new(image);
using Image source = Image.FromStream(stream);
if (IsPowerOfTwo(source.Width) && IsPowerOfTwo(source.Height))
return false; // already power of two
int newWidth = FlooredPowerOfTwo(source.Width);
int newHeight = FlooredPowerOfTwo(source.Height);
Resize(ref image, newWidth, newHeight);
return true;
}
public static (Guid hashGuid, int width, int height) ExtractImageInfo(byte[] image)
{
using MemoryStream stream = new(image);
using Image img = Image.FromStream(stream);
int width = img.Width;
int height = img.Height;
stream.Position = 0;
using SHA256 sha256 = SHA256.Create();
byte[] hashBytes = sha256.ComputeHash(stream);
Guid hashGuid = new(hashBytes.Take(16).ToArray());
return (hashGuid, width, height);
}
}