From d3beca801490d0d616ddfdea60e14895c0d73fce Mon Sep 17 00:00:00 2001 From: Ikatono Date: Tue, 23 Apr 2024 03:24:23 -0500 Subject: [PATCH] basically function but needs a lot of refinement --- CardEditImageBox.cs | 31 +++++++++ CardImagePicker.cs | 16 +++++ Command.cs | 49 ++++---------- CommandHandler.cs | 80 +++++++++++++++++------ CommandParser.cs | 44 ------------- ConfigStretchContainer.cs | 54 ++++++++++++++++ ImageWithMetadata.cs | 39 ++++++++++++ PictureDropHandler.cs | 11 +++- Settings.cs | 14 +++- TwitchChatWatcher.cs | 130 +++++++++++++++++++++++++++++++------- card.cs | 100 +++++++++++++++++++---------- card.tscn | 28 +++----- card_edit_popup.cs | 130 ++++++++++++++++++++++++++++++++++++++ card_edit_popup.tscn | 97 ++++++++++++++++++++++++++++ card_image_picker.tscn | 15 +++++ card_preview.cs | 15 +++-- card_preview.tscn | 45 +++++-------- defer_manager.cs | 81 ++++++++++++++++++++++++ defer_manager.tscn | 6 ++ game.cs | 109 +++++++++++--------------------- game.tscn | 49 ++++++++------ project.godot | 13 ++++ row.cs | 9 +++ row.tscn | 9 +-- settings_popup.cs | 52 +++++++++++++-- settings_popup.tscn | 104 ++++++++++++++++++++++-------- 26 files changed, 985 insertions(+), 345 deletions(-) create mode 100644 CardEditImageBox.cs create mode 100644 CardImagePicker.cs delete mode 100644 CommandParser.cs create mode 100644 ConfigStretchContainer.cs create mode 100644 ImageWithMetadata.cs create mode 100644 card_edit_popup.cs create mode 100644 card_edit_popup.tscn create mode 100644 card_image_picker.tscn create mode 100644 defer_manager.cs create mode 100644 defer_manager.tscn diff --git a/CardEditImageBox.cs b/CardEditImageBox.cs new file mode 100644 index 0000000..a43bdd8 --- /dev/null +++ b/CardEditImageBox.cs @@ -0,0 +1,31 @@ +using Godot; +using System; + +public partial class CardEditImageBox : TextureRect +{ + // Called when the node enters the scene tree for the first time. + public override void _Ready() + { + } + + // Called every frame. 'delta' is the elapsed time since the previous frame. + public override void _Process(double delta) + { + } + public override void _GuiInput(InputEvent @event) + { + if (@event is InputEventMouseButton iemb) + { + if (iemb.Pressed && iemb.ButtonIndex == MouseButton.Left) + { + // bool inControl = _HasPoint(iemb.Position); + // GD.Print(inControl); + // if (iemb.Position.X >= 0 && iemb.Position.X <= Size.X + // && iemb.Position.Y >= 0 && iemb.Position.Y <= Size.Y) + // if (inControl) + GetNode("%CardImagePicker").Show(); + + } + } + } +} diff --git a/CardImagePicker.cs b/CardImagePicker.cs new file mode 100644 index 0000000..704382d --- /dev/null +++ b/CardImagePicker.cs @@ -0,0 +1,16 @@ +using Godot; +using System; + +public partial class CardImagePicker : FileDialog +{ + // Called when the node enters the scene tree for the first time. + public override void _Ready() + { + // FileSelected += GetNode("/game/CardEditPopup").FileSelected; + } + + // Called every frame. 'delta' is the elapsed time since the previous frame. + public override void _Process(double delta) + { + } +} diff --git a/Command.cs b/Command.cs index b9b3c48..6f40aa7 100644 --- a/Command.cs +++ b/Command.cs @@ -105,42 +105,19 @@ public static class CommandTypeHelper // return (CommandType)ct; // return null; var c = commandType.ToLower(); - if (c == "movecard") + return c switch { - return CommandType.MoveCard; - } - else if (c == "moverow") - { - return CommandType.MoveRow; - } - else if (c == "deletecard") - { - return CommandType.DeleteCard; - } - else if (c == "createcard") - { - return CommandType.CreateCard; - } - else if (c == "createrow") - { - return CommandType.CreateRow; - } - else if (c == "renamecard") - { - return CommandType.RenameCard; - } - else if (c == "renamerow") - { - return CommandType.RenameRow; - } - else if (c == "recolorrow") - { - return CommandType.RecolorRow; - } - else if (c == "changecardimage") - { - return CommandType.ChangeCardImage; - } - return null; + "movecard" => CommandType.MoveCard, + "moverow" => CommandType.MoveRow, + "deletecard" or "deletecards" => CommandType.DeleteCard, + "deleterow" => CommandType.DeleteRow, + "createcard" or "addcard" => CommandType.CreateCard, + "createrow" or "addrow" => CommandType.CreateRow, + "renamecard" => CommandType.RenameCard, + "renamerow" => CommandType.RenameRow, + "recolorrow" or "changerowcolor" => CommandType.RecolorRow, + "changecardimage" or "changecardpicture" => CommandType.ChangeCardImage, + _ => null, + }; } } \ No newline at end of file diff --git a/CommandHandler.cs b/CommandHandler.cs index 8a5f45d..0f247c5 100644 --- a/CommandHandler.cs +++ b/CommandHandler.cs @@ -1,18 +1,20 @@ using Godot; using System; using System.Collections; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; public partial class CommandHandler : Node { private Settings Settings { get; set; } private game Game { get; set; } - private readonly List TaskQueue = new(); + private readonly ConcurrentQueue ActionQueue = new(); private System.Net.Http.HttpClient _Client = null; private System.Net.Http.HttpClient Client { get @@ -21,34 +23,42 @@ public partial class CommandHandler : Node return _Client; } } + private defer_manager _Deferer = null; + private defer_manager Deferer + { get + { + _Deferer ??= GetNode("/root/DeferManager"); + return _Deferer; + } + } // Called when the node enters the scene tree for the first time. public override void _Ready() { Settings = GetNode("/root/Settings"); Game = GetNode("/root/Game"); + GetNode("/root/TwitchChatWatcher").IncomingCommand + += IncomingCommand; } // Called every frame. 'delta' is the elapsed time since the previous frame. public override void _Process(double delta) { - //delete/cleanup one finished task per frame - //currently just logs errors - var t = TaskQueue.FirstOrDefault(t => t.IsCompleted, null); - if (t is not null) + while (ActionQueue.TryDequeue(out Action t)) { - if (t.Exception is not null) - GD.PrintErr(t.Exception); - TaskQueue.Remove(t); + t?.Invoke(); } } private void IncomingCommand(Command command) { + GD.Print("Received command"); if (!Settings.IsUserAuthorized(command.User, command.IsStreamer, command.IsModerator)) return; + GD.Print($"User {command.User} is authorized"); var baseArgs = command.GetArgs(); var type = CommandTypeHelper.ParseCommand(baseArgs.Pop()); + GD.Print($"Command type: {type}"); var args = baseArgs.DuplicateAtState(); switch (type) { @@ -65,7 +75,9 @@ public partial class CommandHandler : Node DeleteRow(args); break; case CommandType.CreateCard: - TaskQueue.Add(Task.Run(() => CreateCard(args))); + Task.Run(async () => await CreateCard(args)) + .ContinueWith(t => GD.PrintErr(t.Exception.StackTrace), + TaskContinuationOptions.OnlyOnFaulted); break; case CommandType.CreateRow: CreateRow(args); @@ -80,10 +92,12 @@ public partial class CommandHandler : Node RecolorRow(args); break; case CommandType.ChangeCardImage: - TaskQueue.Add(Task.Run(() => ChangeCardImage(args))); + Task.Run(async () => await ChangeCardImage(args)) + .ContinueWith(t => GD.PushError(t.Exception.InnerException.StackTrace), + TaskContinuationOptions.OnlyOnFaulted); break; default: - throw new Exception(); + throw new Exception("invalid command type"); } } @@ -120,10 +134,14 @@ public partial class CommandHandler : Node } private async Task CreateCard(CommandArguments args) { - var title = args.Pop(); var url = args.Pop(); - Image img = await ImageFromUrl(url); - Game.CreateCard(title, img); + var title = args.Remaining(); + ImageWithMetadata img; + if (!string.IsNullOrWhiteSpace(url) && url != "_") + img = await ImageFromUrl(url); + else + img = null; + await Deferer.DeferAsync(() => Game.CreateCard(title, img)); } private void CreateRow(CommandArguments args) { @@ -152,7 +170,8 @@ public partial class CommandHandler : Node var rowId = args.Pop(); var colorStr = args.Pop(); var color = Color.FromString(colorStr, new Color(0, 0, 0, 0)); - if (color == new Color(0, 0, 0, 0)) + GD.Print($"Recoloring row to {color}"); + if (color.IsEqualApprox(new Color(0, 0, 0, 0))) throw new Exception($"invalid color {colorStr}"); Game.RecolorRow(rowId, color); } @@ -160,18 +179,38 @@ public partial class CommandHandler : Node { var cardId = args.Pop(); var img = await ImageFromUrl(args.Pop()); - Game.ChangeCardImage(cardId, img); + await Deferer.DeferAsync(() => Game.ChangeCardImage(cardId, img)); + // Game.ChangeCardImage(cardId, img); } - private async Task ImageFromUrl(string url) + private async Task ImageFromUrl(string url) { + StretchMode mode = StretchMode.Unspecified; + if (url.Contains('|')) + { + var spl = url.Split('|', 2); + url = spl[0]; + mode = spl[1].ToLower() switch + { + "unspecified" => StretchMode.Unspecified, + "fit" => StretchMode.Fit, + "stretch" => StretchMode.Stretch, + "crop" => StretchMode.Crop, + _ => throw new Exception($"Unrecognized {nameof(StretchMode)}"), + }; + } + GD.Print($"Stretch mode: {mode}"); var uri = new Uri(url); + GD.Print("Starting image download"); var resp = await Client.GetAsync(uri); if (!resp.IsSuccessStatusCode) - return null; + throw new Exception("Failed to download image"); + GD.Print("Downloaded image successfully"); Image img = new(); var arr = await resp.Content.ReadAsByteArrayAsync(); + var ext = Path.GetExtension(uri.AbsolutePath).TrimStart('.').ToLower(); + GD.Print($"Image extension: {ext}"); //TODO detect images by header rather than extension - switch (Path.GetExtension(uri.AbsolutePath).ToLower()) + switch (ext) { case "png": img.LoadPngFromBuffer(arr); @@ -189,7 +228,8 @@ public partial class CommandHandler : Node default: throw new Exception("unrecognized filetype"); } - return img; + GD.Print($"Loaded picture {img}"); + return new ImageWithMetadata(img, mode); } protected override void Dispose(bool disposing) { diff --git a/CommandParser.cs b/CommandParser.cs deleted file mode 100644 index 8e10921..0000000 --- a/CommandParser.cs +++ /dev/null @@ -1,44 +0,0 @@ -// using System.Collections.Generic; - -// public static class CommandParser -// { -// public static List SplitString(string s) -// { -// List l = new(); -// bool quoted = false; -// bool escaped = false; -// bool trimming = true; -// string current = ""; -// foreach (char c in s) -// { -// switch (c) -// { -// case '\\': -// if (escaped) -// current += '\\'; -// escaped = !escaped; -// break; -// case '"': -// if (escaped) -// { -// current += '"'; -// escaped = false; -// } -// else -// { -// if (quoted) -// { -// quoted = false; - -// } -// else -// { - -// } -// } -// break; -// case -// } -// } -// } -// } \ No newline at end of file diff --git a/ConfigStretchContainer.cs b/ConfigStretchContainer.cs new file mode 100644 index 0000000..e2d6c48 --- /dev/null +++ b/ConfigStretchContainer.cs @@ -0,0 +1,54 @@ +using Godot; +using System; + +public partial class ConfigStretchContainer : VBoxContainer +{ + private Settings _Settings; + private Settings Settings + { get + { + _Settings ??= GetNode("/root/Settings"); + return _Settings; + } + } + // Called when the node enters the scene tree for the first time. + public override void _Ready() + { + } + + // Called every frame. 'delta' is the elapsed time since the previous frame. + public override void _Process(double delta) + { + } + public void LoadButtonState() + { + switch (Settings.StretchMode) + { + case StretchMode.Fit: + GetNode("%ConfigStretchFitButton").ButtonPressed = true; + break; + case StretchMode.Crop: + GetNode("%ConfigStretchCropButton").ButtonPressed = true; + break; + case StretchMode.Stretch: + GetNode("%ConfigStretchStretchButton").ButtonPressed = true; + break; + default: + throw new Exception($"Unrecognized {nameof(StretchMode)} {(int)Settings.StretchMode}"); + } + } + public StretchMode GetStretchMode() + { + if (GetNode("%ConfigStretchFitButton").ButtonPressed) + return StretchMode.Fit; + else if (GetNode("%ConfigStretchCropButton").ButtonPressed) + return StretchMode.Crop; + else if (GetNode("%ConfigStretchStretchButton").ButtonPressed) + return StretchMode.Stretch; + throw new Exception($"No {nameof(StretchMode)} buttons pressed"); + } + public void OkClicked() + { + Settings.StretchMode = GetStretchMode(); + } +} diff --git a/ImageWithMetadata.cs b/ImageWithMetadata.cs new file mode 100644 index 0000000..8de488b --- /dev/null +++ b/ImageWithMetadata.cs @@ -0,0 +1,39 @@ +using System.Data.Common; +using Godot; + +public record class ImageWithMetadata +{ + public Image Image; + public StretchMode StretchMode; + public ImageWithMetadata(Image image) + { + Image = image; + StretchMode = StretchMode.Unspecified; + } + public ImageWithMetadata(Image image, StretchMode stretchMode) + : this(image) + { + StretchMode = stretchMode; + } + // public static implicit operator Image(ImageWithMetadata iwm) + // => iwm.Image; + // public static implicit operator ImageWithMetadata(Image image) + // => new(image); +} + +public enum StretchMode +{ + Unspecified, + /// + /// Fit to either the width or height of the card + /// + Fit, + /// + /// Stretch the image to fill the card + /// + Stretch, + /// + /// Crop image to fit card (maintain center) + /// + Crop, +} \ No newline at end of file diff --git a/PictureDropHandler.cs b/PictureDropHandler.cs index 4b4a09a..ee46cb2 100644 --- a/PictureDropHandler.cs +++ b/PictureDropHandler.cs @@ -3,8 +3,16 @@ using System; using System.Collections; using System.Collections.Generic; -public partial class PictureDropHandler : Control +public partial class PictureDropHandler : Node { + private Settings _Settings; + private Settings Settings + { get + { + _Settings ??= GetNode("/root/Settings"); + return _Settings; + } + } // Called when the node enters the scene tree for the first time. public override void _Ready() { @@ -28,6 +36,7 @@ public partial class PictureDropHandler : Control continue; var c = card.MakeCard(GetTree()); c.SetImage(tex); + c.SetStretchMode(Settings.StretchMode); g.AddUnassignedCard(c); } } diff --git a/Settings.cs b/Settings.cs index 24068cb..5ca4441 100644 --- a/Settings.cs +++ b/Settings.cs @@ -1,17 +1,24 @@ using System.Collections.Generic; +using System.Linq; using Godot; public partial class Settings : Node { + [Export] public bool AllowStreamer { get; set; } + [Export] public bool AllowModerators { get; set; } + [Export] + public string Command { get; set; } public List UserWhitelist { get; } = new(); public List UserBlacklist { get; } = new(); public Vector2 CardSize { get; private set; } + [Export] + public StretchMode StretchMode { get; set; } = StretchMode.Fit; [Signal] public delegate void ChangeCardSizeEventHandler(Vector2 size); - + public void SetCardSize(Vector2 size) { CardSize = size; @@ -19,6 +26,7 @@ public partial class Settings : Node } public bool IsUserAuthorized(string user, bool isStreamer = false, bool isModerator = false) { + user = user.ToLower(); if (UserBlacklist.Contains(user)) return false; if (UserWhitelist.Contains(user)) @@ -32,8 +40,8 @@ public partial class Settings : Node public void SetUserLists(IEnumerable white, IEnumerable black) { UserWhitelist.Clear(); - UserWhitelist.AddRange(white); + UserWhitelist.AddRange(white.Select(s => s.ToLower())); UserBlacklist.Clear(); - UserBlacklist.AddRange(black); + UserBlacklist.AddRange(black.Select(s => s.ToLower())); } } \ No newline at end of file diff --git a/TwitchChatWatcher.cs b/TwitchChatWatcher.cs index 4173b4d..ae91585 100644 --- a/TwitchChatWatcher.cs +++ b/TwitchChatWatcher.cs @@ -15,22 +15,35 @@ public partial class TwitchChatWatcher : Node private readonly CancellationTokenSource TokenSource = new(); public CancellationToken Token => TokenSource.Token; private CommandHandler CommandHandler { get; set; } + public WebSocketState State => Socket.State; + [Export] + public bool PrintAllIncoming { get; set; } + private Settings Settings; [Signal] public delegate void IncomingCommandEventHandler(Command command); // Called when the node enters the scene tree for the first time. public override void _Ready() { - CommandHandler = GetNode("/root/CommandHandler"); - + Settings = GetNode("/root/Settings") + ?? throw new Exception($"{nameof(Settings)} node not found"); + CommandHandler = GetNode("/root/CommandHandler") + ?? throw new Exception($"{nameof(Command)} not found"); } - + private readonly ConcurrentQueue PrintQueue = new(); + private readonly ConcurrentQueue ErrorQueue = new(); + // Called every frame. 'delta' is the elapsed time since the previous frame. public override void _Process(double delta) { + if (PrintQueue.TryDequeue(out string s)) + GD.Print(s); + if (ErrorQueue.TryDequeue(out string e)) + GD.PrintErr(e); } public async Task ConnectAsync() { + GD.Print("Connecting"); if (Socket.State == WebSocketState.Open) return; await Socket.ConnectAsync(new Uri("wss://irc-ws.chat.twitch.tv:443"), Token); @@ -40,15 +53,22 @@ public partial class TwitchChatWatcher : Node } public async Task Authenticate(string user = null, string pass = null) { + GD.Print("Authenticating"); user ??= $"justinfan{Random.Shared.NextInt64(10000):D4}"; pass ??= "pass"; await SendMessageAsync(TwitchChatMessageType.PASS, parameters: new string[] { pass }); await SendMessageAsync(TwitchChatMessageType.NICK, parameters: new string[] { user }); } + public async Task RequestTags() + { + await SendMessageAsync("CAP REQ :twitch.tv/tags"); + } public async Task JoinChannel(string channel) { + GD.Print("Joining channel"); channel = channel.TrimStart('#'); - await SendMessageAsync(TwitchChatMessageType.JOIN, parameters: new string[] {"#" + channel}); + await SendMessageAsync(TwitchChatMessageType.JOIN, + parameters: new string[] {"#" + channel}); } public async Task SendMessageAsync(string message) { @@ -58,7 +78,7 @@ public partial class TwitchChatWatcher : Node public async Task SendMessageAsync(TwitchChatMessageType command, IEnumerable parameters = null, IDictionary tags = null, string prefix = null) { - string EscapeTagValue(string s) + static string EscapeTagValue(string s) { if (s is null) return ""; @@ -89,41 +109,103 @@ public partial class TwitchChatWatcher : Node } await SendMessageAsync(message); } + private static ulong PacketCount; private async Task GetPacketsTask() { - var buffer = ArraySegment.Empty; - var stringData = ""; - while (!Token.IsCancellationRequested) + try { - var res = await Socket.ReceiveAsync(buffer, Token); - if (Token.IsCancellationRequested) - return; - stringData += Encoding.UTF8.GetString(buffer); - var lines = stringData.Split("\r\n", StringSplitOptions.TrimEntries); - stringData = lines.Last(); - foreach (var line in lines.SkipLast(1)) - MessageStrings.Enqueue(line); + var arr = new byte[16 * 1024]; + var stringData = ""; + while (!Token.IsCancellationRequested) + { + var res = await Socket.ReceiveAsync(arr, Token); + if (Token.IsCancellationRequested) + return; + if (Socket.State != WebSocketState.Open) + { + ErrorQueue.Enqueue("Socket closed"); + return; + } + if (res.Count == 0) + { + ErrorQueue.Enqueue("Empty packet received"); + continue; + } + PacketCount++; + PrintQueue.Enqueue($"Packet count: {PacketCount}"); + stringData += Encoding.UTF8.GetString(arr, 0, res.Count); + //PrintQueue.Enqueue(stringData); + var lines = stringData.Split("\r\n", StringSplitOptions.TrimEntries); + if (!lines.Any()) + continue; + stringData = lines.Last(); + PrintQueue.Enqueue($"Line count: {lines.SkipLast(1).Count()}"); + foreach (var line in lines.SkipLast(1)) + MessageStrings.Enqueue(line); + } + } + catch (Exception e) + { + ErrorQueue.Enqueue(e.ToString()); + } + finally + { + if (!Token.IsCancellationRequested) + ErrorQueue.Enqueue($"{nameof(GetPacketsTask)} exited without cancellation"); + else + PrintQueue.Enqueue($"{nameof(GetPacketsTask)} cancelled and exited"); } } private readonly ConcurrentQueue MessageStrings = new(); - private void HandleMessages() + private async Task HandleMessages() { - while (MessageStrings.TryDequeue(out string message)) + try { - var tcm = TwitchChatMessage.Parse(message); - if (tcm.MessageType == TwitchChatMessageType.PING) - _ = SendPong(tcm); - else if (tcm is Privmsg p) + while (!Token.IsCancellationRequested) { - EmitSignal(SignalName.IncomingCommand, new Command(p.DisplayName, - false, p.Moderator, p.ChatMessage)); + while (MessageStrings.TryDequeue(out string message)) + { + if (string.IsNullOrWhiteSpace(message)) + continue; + PrintQueue.Enqueue(message); + // if (PrintAllIncoming) + // PrintQueue.Enqueue(message); + var tcm = TwitchChatMessage.Parse(message); + if (tcm.MessageType == TwitchChatMessageType.PING) + _ = Task.Run(() => SendPong(tcm), Token); + else if (tcm is Privmsg p) + { + var com = Settings.Command; + if (!p.ChatMessage.StartsWith(com)) + continue; + var chat = p.ChatMessage; + chat = chat[com.Length..].TrimStart(); + CallDeferred("emit_signal", SignalName.IncomingCommand, + new Command(p.DisplayName, + false, p.Moderator, chat)); + } + } + await Task.Delay(50); } } + catch (Exception e) + { + ErrorQueue.Enqueue(e.ToString() + System.Environment.NewLine + + e.StackTrace); + } + finally + { + if (!Token.IsCancellationRequested) + ErrorQueue.Enqueue($"{nameof(HandleMessages)} exited without cancellation"); + else + ErrorQueue.Enqueue($"{nameof(HandleMessages)} cancelled and exited"); + } } private async Task SendPong(TwitchChatMessage ping) { var pong = TwitchChatMessage.MakePong(ping); await SendMessageAsync(TwitchChatMessageType.PONG, ping.Parameters, ping.MessageTags, ping.Prefix); + PrintQueue.Enqueue("Sent Pong"); } } diff --git a/card.cs b/card.cs index 414f55d..486b2b3 100644 --- a/card.cs +++ b/card.cs @@ -1,7 +1,6 @@ using Godot; -using System; -public partial class card : Control +public partial class card : Panel { private Settings Settings => GetNode("/root/Settings"); [Export] @@ -18,7 +17,7 @@ public partial class card : Control } private void PropogateCardName() { - GetNode