diff --git a/Modules/CustomPalette.cs b/Modules/CustomPalette.cs index 81a7f5cd4..01659bb5c 100644 --- a/Modules/CustomPalette.cs +++ b/Modules/CustomPalette.cs @@ -6,5 +6,7 @@ public static class CustomPalette { public static readonly Color32 EnabledGreen = new(0, 255, 127, 255); public static readonly Color32 DisabledRed = new(255, 99, 71, 255); + public static readonly Color32 SucceededBlue = new(0, 144, 255, 255); + public static readonly Color32 FailedRed = new(255, 0, 148, 255); } } diff --git a/Modules/Webhook/WebhookManager.cs b/Modules/Webhook/WebhookManager.cs index e4ce61d61..b0b5d2801 100644 --- a/Modules/Webhook/WebhookManager.cs +++ b/Modules/Webhook/WebhookManager.cs @@ -22,22 +22,31 @@ public sealed class WebhookManager : IDisposable private static readonly ILogHandler logger = Logger.Handler(nameof(WebhookManager)); private bool disposedValue; - public void StartSend(WebhookMessageBuilder builder) + public void StartSend(WebhookMessageBuilder builder, Action onComplete = default) { - if (!TryReadUrl(out var url)) - { - logger.Warn("URL設定が正しくありません"); - return; - } - var sendTask = SendAsync(builder, url); - sendTask.ContinueWith(task => + try { - if (task.Exception is { } aggregateException) + if (!TryReadUrl(out var url)) { - logger.Warn("送信中に例外が発生しました"); - logger.Exception(aggregateException.InnerException); + logger.Warn("URL設定が正しくありません"); + onComplete?.Invoke(new(true, FailureReason.InvalidUrl)); + return; } - }); + var sendTask = SendAsync(builder, url, onComplete); + sendTask.ContinueWith(task => + { + if (task.Exception is { } aggregateException) + { + logger.Warn("送信中に例外が発生しました"); + logger.Exception(aggregateException.InnerException); + } + }); + } + catch + { + onComplete?.Invoke(new(true, FailureReason.Exception)); + throw; + } } private bool TryReadUrl(out string url) { @@ -79,39 +88,71 @@ private bool ValidateUrl(string url) } return webhookUrlRegex.IsMatch(url); } - public async Task SendAsync(WebhookMessageBuilder builder, string url, CancellationToken cancellationToken = default) + public async Task SendAsync(WebhookMessageBuilder builder, string url, Action onComplete = default, CancellationToken cancellationToken = default) { - var fullMessage = builder.ContentBuilder.ToString(); - if (fullMessage.Length <= MaxContentLength) - { - await SendInnerAsync(fullMessage, builder.UserName, builder.AvatarUrl, url, cancellationToken); - return; - } - // 改行を区切りとして,上限文字数を超えないように分割して送信する - // 1行で上限を超えているケースは考慮しない - var lines = fullMessage.Split(Environment.NewLine); - var partBuilder = new StringBuilder(); - foreach (var line in lines) + try { - if (partBuilder.Length + line.Length > MaxContentLength) + var fullMessage = builder.ContentBuilder.ToString(); + if (fullMessage.Length <= MaxContentLength) + { + if (await SendInnerAsync(fullMessage, builder.UserName, builder.AvatarUrl, url, cancellationToken)) + { + onComplete?.Invoke(new(false)); + } + else + { + onComplete?.Invoke(new(true, FailureReason.Network)); + } + return; + } + + var hasFailure = false; + // 改行を区切りとして,上限文字数を超えないように分割して送信する + // 1行で上限を超えているケースは考慮しない + var lines = fullMessage.Split(Environment.NewLine); + var partBuilder = new StringBuilder(); + foreach (var line in lines) + { + if (partBuilder.Length + line.Length > MaxContentLength) + { + if (!await SendInnerAsync(partBuilder.ToString(), builder.UserName, builder.AvatarUrl, url, cancellationToken)) + { + hasFailure = true; + } + partBuilder.Clear(); + await Task.Delay(1000, cancellationToken); + } + partBuilder.AppendLine(line); + } + if (partBuilder.Length > 0) { - await SendInnerAsync(partBuilder.ToString(), builder.UserName, builder.AvatarUrl, url, cancellationToken); - partBuilder.Clear(); - await Task.Delay(1000, cancellationToken); + if (!await SendInnerAsync(partBuilder.ToString(), builder.UserName, builder.AvatarUrl, url, cancellationToken)) + { + hasFailure = true; + } + } + + if (hasFailure) + { + onComplete?.Invoke(new(true, FailureReason.Network)); + } + else + { + onComplete?.Invoke(new(false)); } - partBuilder.AppendLine(line); } - if (partBuilder.Length > 0) + catch { - await SendInnerAsync(partBuilder.ToString(), builder.UserName, builder.AvatarUrl, url, cancellationToken); + onComplete?.Invoke(new(true, FailureReason.Exception)); + throw; } } - private async Task SendInnerAsync(string message, string userName, string avatarUrl, string url, CancellationToken cancellationToken = default) + private async Task SendInnerAsync(string message, string userName, string avatarUrl, string url, CancellationToken cancellationToken = default) { var content = new WebhookRequest(message, userName, avatarUrl); - await SendAsync(content, url, cancellationToken); + return await SendAsync(content, url, cancellationToken); } - private async Task SendAsync(WebhookRequest webhookRequest, string url, CancellationToken cancellationToken = default) + private async Task SendAsync(WebhookRequest webhookRequest, string url, CancellationToken cancellationToken = default) { try { @@ -120,12 +161,15 @@ private async Task SendAsync(WebhookRequest webhookRequest, string url, Cancella if (!response.IsSuccessStatusCode) { logger.Warn("送信に失敗"); + return false; } + return true; } catch (TaskCanceledException taskCanceledException) { logger.Warn("送信はキャンセルされました"); logger.Exception(taskCanceledException); + return false; } } @@ -147,6 +191,21 @@ public void Dispose() GC.SuppressFinalize(this); } + public readonly struct OnCompleteArgs(bool hasFailure, FailureReason failureReason = default) + { + public bool HasFailure { get; } = hasFailure; + public FailureReason FailureReason { get; } = failureReason; + } + public readonly struct FailureReason(string message) + { + public string Message { get; } = message; + + public static FailureReason InvalidUrl { get; } = new("URL設定が正しくありません。設定を確認してください"); + public static FailureReason Exception { get; } = new("処理中にエラーが発生しました"); + public static FailureReason Network { get; } = new("送信を完了できませんでした。\nネットワーク品質、Modの不具合、Discord側の問題が原因の可能性があります"); + + } + public FileInfo WebhookUrlFile { get; } = #if DEBUG new("DebugWebhookUrl.txt"); diff --git a/Objects/FlatButton.cs b/Objects/FlatButton.cs new file mode 100644 index 000000000..d79417b68 --- /dev/null +++ b/Objects/FlatButton.cs @@ -0,0 +1,91 @@ +using System; +using TMPro; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace TownOfHost.Objects; + +public sealed class FlatButton +{ + public FlatButton(Transform parent, string name, Vector3 localPosition, Color32 normalColor, Color32 hoverColor, Action action, string label, Vector2 scale) + { + Button = Object.Instantiate(buttonPrefab, parent); + Label = Button.transform.Find("FontPlacer/Text_TMP").GetComponent(); + NormalRenderer = Button.inactiveSprites.GetComponent(); + HoverRenderer = Button.activeSprites.GetComponent(); + ButtonCollider = Button.GetComponent(); + + var container = Label.transform.parent; + Object.Destroy(Label.GetComponent()); + container.SetLocalX(0f); + Label.transform.SetLocalX(0f); + Label.horizontalAlignment = HorizontalAlignmentOptions.Center; + + NormalRenderer.color = normalColor; + HoverRenderer.color = hoverColor; + + Button.name = name; + Button.transform.localPosition = localPosition; + Button.OnClick.AddListener(action); + Label.text = label; + Scale = scale; + Button.gameObject.SetActive(true); + } + + public PassiveButton Button { get; } + public TextMeshPro Label { get; } + public SpriteRenderer NormalRenderer { get; } + public SpriteRenderer HoverRenderer { get; } + public BoxCollider2D ButtonCollider { get; } + private Vector2 _scale; + public Vector2 Scale + { + get => _scale; + set => _scale = NormalRenderer.size = HoverRenderer.size = ButtonCollider.size = value; + } + private float _fontSize; + public float FontSize + { + get => _fontSize; + set => _fontSize = Label.fontSize = Label.fontSizeMin = Label.fontSizeMax = value; + } + + private static PassiveButton buttonPrefab = CreatePrefab(); + private static PassiveButton CreatePrefab() + { + if (Prefabs.SimpleButton == null) + { + throw new InvalidOperationException("SimpleButtonのプレファブが未設定"); + } + + var button = Object.Instantiate(Prefabs.SimpleButton); + var label = button.transform.Find("FontPlacer/Text_TMP").GetComponent(); + var normalRenderer = button.inactiveSprites.GetComponent(); + var hoverRenderer = button.activeSprites.GetComponent(); + + Object.Destroy(button.GetComponent()); + + // 初回だけちょっと重いのどうにかしたい + var texture = new Texture2D(normalRenderer.sprite.texture.width, normalRenderer.sprite.texture.height, TextureFormat.ARGB32, false); + for (var x = 0; x < texture.width; x++) + { + for (var y = 0; y < texture.height; y++) + { + texture.SetPixel(x, y, Color.white); + } + } + texture.Apply(); + var normalSprite = Sprite.Create(texture, new(0f, 0f, texture.width, texture.height), new(0.5f, 0.5f)); + var hoverSprite = Sprite.Create(texture, new(0f, 0f, texture.width, texture.height), new(0.5f, 0.5f)); + normalRenderer.sprite = normalSprite; + hoverRenderer.sprite = hoverSprite; + + button.name = "FlatButtonPrefab"; + label.text = "Flat Button Prefab"; + button.gameObject.SetActive(false); + Object.DontDestroyOnLoad(button); + return button; + } + + public static bool IsNullOrDestroyed(FlatButton flatButton) => flatButton == null || flatButton.Button == null; +} diff --git a/Objects/Prefabs.cs b/Objects/Prefabs.cs index 91f6fa25c..0e8df3f9c 100644 --- a/Objects/Prefabs.cs +++ b/Objects/Prefabs.cs @@ -27,5 +27,30 @@ public static TextMeshPro SimpleText } } } + private static PassiveButton _simpleButton; + public static PassiveButton SimpleButton + { + get => _simpleButton; + set + { + if (_simpleButton != null) + { + return; + } + _simpleButton = Object.Instantiate(value); + var label = _simpleButton.transform.Find("FontPlacer/Text_TMP").GetComponent(); + _simpleButton.gameObject.SetActive(false); + Object.DontDestroyOnLoad(_simpleButton); + _simpleButton.name = "SimpleButtonPrefab"; + Object.Destroy(_simpleButton.GetComponent()); + label.DestroyTranslator(); + label.fontSize = label.fontSizeMax = label.fontSizeMin = 3.5f; + label.enableWordWrapping = false; + label.text = "Simple Button Prefab"; + var buttonCollider = _simpleButton.GetComponent(); + buttonCollider.offset = new(0f, 0f); + _simpleButton.OnClick = new(); + } + } } } diff --git a/Patches/GameStartManagerPatch.cs b/Patches/GameStartManagerPatch.cs index af7f8ea1a..a3daac380 100644 --- a/Patches/GameStartManagerPatch.cs +++ b/Patches/GameStartManagerPatch.cs @@ -15,6 +15,9 @@ using TMPro; using static TownOfHost.Translator; using TownOfHost.Roles; +using TownOfHost.Modules.Webhook; +using System.Collections; +using BepInEx.Unity.IL2CPP.Utils.Collections; namespace TownOfHost { @@ -26,6 +29,10 @@ public class GameStartManagerPatch private static TextMeshPro timerText; private static SpriteRenderer cancelButton; public static int CurrentGameId = 32; + public static WebhookMessageBuilder LastResultMessage { get; set; } = null; + private static FlatButton SendWebhookButton; + private const string SendWebhookButtonLabel = "Discordに結果を送信"; + private static WebhookManager.OnCompleteArgs? completedWebhookResult; [HarmonyPatch(typeof(GameStartManager), nameof(GameStartManager.Start))] public class GameStartManagerStartPatch @@ -74,6 +81,27 @@ public static void Postfix(GameStartManager __instance) LobbySummary.Show(); + if (LastResultMessage != null) + { + SendWebhookButton = new( + __instance.transform, + "SendWebhookButton", + new(4.4f, 3.6f, -1f), + new(88, 101, 242, byte.MaxValue), + new(127, 138, 239, byte.MaxValue), + () => + { + SendWebhookButton.Label.text = "送信中..."; + SendWebhookButton.ButtonCollider.enabled = false; + WebhookManager.Instance.StartSend(LastResultMessage, args => completedWebhookResult = args); + }, + SendWebhookButtonLabel, + new(2f, 0.5f)) + { + FontSize = 2.5f, + }; + } + if ( AmongUsClient.Instance.NetworkMode == NetworkModes.OnlineGame && AmongUsClient.Instance.AmHost && @@ -224,6 +252,62 @@ private static bool MatchVersions(byte playerId, bool acceptVanilla = false) && version.tag == $"{ThisAssembly.Git.Commit}({ThisAssembly.Git.Branch})" && version.forkVersion == Main.ForkVersion; } + + [HarmonyPostfix, HarmonyPriority(Priority.Low)] + public static void Postfix2(GameStartManager __instance) + { + if (completedWebhookResult.HasValue) + { + OnSendComplete(__instance, completedWebhookResult.Value); + completedWebhookResult = null; + } + } + private static void OnSendComplete(GameStartManager __instance, WebhookManager.OnCompleteArgs args) + { + if (!FlatButton.IsNullOrDestroyed(SendWebhookButton)) + { + if (args.HasFailure) + { + SendWebhookButton.Button.StartCoroutine(CoShowFailedAndReactivate(SendWebhookButton).WrapToIl2Cpp()); + } + else + { + SendWebhookButton.Button.StartCoroutine(CoShowSucceededAndHide(SendWebhookButton).WrapToIl2Cpp()); + } + } + var notifyText = Object.Instantiate(Prefabs.SimpleText, __instance.transform); + notifyText.name = "DiscordResultNotify"; + notifyText.text = args.HasFailure ? $"送信に失敗しました\n\n{args.FailureReason.Message}" : "送信に成功しました"; + notifyText.color = Color.white; + notifyText.outlineWidth = 0.07f; + notifyText.outlineColor = args.HasFailure ? CustomPalette.FailedRed : CustomPalette.SucceededBlue; + notifyText.fontSize = notifyText.fontSizeMax = notifyText.fontSizeMin = 4f; + notifyText.transform.localPosition = new(0f, 2f, -10f); + notifyText.gameObject.SetActive(true); + __instance.StartCoroutine(Effects.Lerp(args.HasFailure ? 10f : 3f, new Action(t => + { + var alpha = 1f - t; + if (alpha <= 0f) + { + Object.Destroy(notifyText.gameObject); + return; + } + notifyText.color = new Color(1f, 1f, 1f, alpha); + }))); + } + private static IEnumerator CoShowSucceededAndHide(FlatButton button) + { + button.Label.text = "送信完了!"; + yield return new WaitForSeconds(5f); + button.Button.gameObject.SetActive(false); + } + private static IEnumerator CoShowFailedAndReactivate(FlatButton button) + { + button.Label.text = "送信失敗"; + yield return new WaitForSeconds(5f); + button.Label.text = SendWebhookButtonLabel; + button.ButtonCollider.enabled = true; + } } [HarmonyPatch(typeof(GameStartManager), nameof(GameStartManager.BeginGame))] @@ -297,6 +381,17 @@ public static void Prefix() } } } + + [HarmonyPatch(typeof(GameStartManager), nameof(GameStartManager.OnDestroy))] + public static class OnDestroyPatch + { + public static void Postfix() + { + LobbySummary.Hide(); + LastResultMessage = null; + SendWebhookButton = null; + } + } } [HarmonyPatch(typeof(TextBoxTMP), nameof(TextBoxTMP.SetText))] @@ -317,13 +412,4 @@ public static bool Prefix(ref int __result) return false; } } - - [HarmonyPatch(typeof(GameStartManager), nameof(GameStartManager.OnDestroy))] - public static class GameStartManagerOnDestroyPatch - { - public static void Postfix() - { - LobbySummary.Hide(); - } - } } diff --git a/Patches/MainMenuManagerPatch.cs b/Patches/MainMenuManagerPatch.cs index 9c6f82557..d2c100a32 100644 --- a/Patches/MainMenuManagerPatch.cs +++ b/Patches/MainMenuManagerPatch.cs @@ -1,6 +1,7 @@ using System; using HarmonyLib; +using TownOfHost.Objects; using TownOfHost.Templates; using UnityEngine; @@ -17,6 +18,7 @@ public class MainMenuManagerPatch public static void StartPostfix(MainMenuManager __instance) { SimpleButton.SetBase(__instance.quitButton); + Prefabs.SimpleButton = __instance.quitButton; //Discordボタンを生成 if (SimpleButton.IsNullOrDestroyed(discordButton)) { diff --git a/Patches/OutroPatch.cs b/Patches/OutroPatch.cs index 6a7bc70d9..706332c2a 100644 --- a/Patches/OutroPatch.cs +++ b/Patches/OutroPatch.cs @@ -296,7 +296,7 @@ public static void Postfix(EndGameManager __instance) if (resultMessageBuilder.ContentBuilder.Length > 0) { - WebhookManager.Instance.StartSend(resultMessageBuilder); + GameStartManagerPatch.LastResultMessage = resultMessageBuilder; } } }