diff --git a/Command.cs b/Command.cs
new file mode 100644
index 0000000..b9b3c48
--- /dev/null
+++ b/Command.cs
@@ -0,0 +1,146 @@
+using Godot;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Text.RegularExpressions;
+
+public partial class Command : GodotObject
+{
+ public string User;
+ public bool IsStreamer;
+ public bool IsModerator;
+ // public CommandType Type;
+ private string _Args;
+ public CommandArguments GetArgs()
+ => new(_Args);
+ public Command(string user, bool isStreamer, bool isModerator,
+ // CommandType type,
+ string args)
+ {
+ User = user;
+ IsStreamer = isStreamer;
+ IsModerator = isModerator;
+ // Type = type;
+ _Args = args;
+ }
+}
+
+///
+/// Manages a list of whitespace-delimited arguments. Does not
+/// respect quotes or escape characters. A single argument containing
+/// whitespace can be placed at the end and retrieved with
+/// after the previous arguments are popped.
+///
+///
+/// Use to safely handle subcommands.
+///
+public class CommandArguments
+{
+ private static readonly Regex _Regex = new(@"\s+");
+ private readonly string OriginalArgs;
+ private string Args;
+ public CommandArguments(string args)
+ {
+ OriginalArgs = args;
+ Args = args;
+ }
+ public string Pop()
+ {
+ var spl = _Regex.Split(Args, 2);
+ Args = spl.ElementAtOrDefault(1) ?? "";
+ return spl[0];
+ }
+ public string Remaining()
+ => Args;
+ public void Reset()
+ {
+ Args = OriginalArgs;
+ }
+ ///
+ /// Enumerates arguments from current state, without changing state
+ ///
+ ///
+ public IEnumerable Enumerate()
+ {
+ var a = DuplicateAtState();
+ while (true)
+ {
+ var s = a.Pop();
+ if (string.IsNullOrWhiteSpace(s))
+ break;
+ yield return s;
+ }
+ }
+ ///
+ /// Creates a new arguments object that returns to this state when reset.
+ /// Any arguments that have already been popped will not return.
+ ///
+ public CommandArguments DuplicateAtState()
+ {
+ return new(Args);
+ }
+}
+
+public enum CommandType
+{
+ MoveCard,
+ MoveRow,
+ DeleteCard,
+ DeleteRow,
+ CreateCard,
+ CreateRow,
+ RenameCard,
+ RenameRow,
+ RecolorRow,
+ ChangeCardImage,
+}
+
+public static class CommandTypeHelper
+{
+ public static CommandType? ParseCommand(string commandType)
+ {
+ // if (Enum.TryParse(typeof(CommandType), commandType, true, out object ct))
+ // return (CommandType)ct;
+ // return null;
+ var c = commandType.ToLower();
+ if (c == "movecard")
+ {
+ 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;
+ }
+}
\ No newline at end of file
diff --git a/CommandHandler.cs b/CommandHandler.cs
new file mode 100644
index 0000000..8a5f45d
--- /dev/null
+++ b/CommandHandler.cs
@@ -0,0 +1,202 @@
+using Godot;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Text.RegularExpressions;
+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 System.Net.Http.HttpClient _Client = null;
+ private System.Net.Http.HttpClient Client
+ { get
+ {
+ _Client ??= new();
+ return _Client;
+ }
+ }
+ // Called when the node enters the scene tree for the first time.
+ public override void _Ready()
+ {
+ Settings = GetNode("/root/Settings");
+ Game = GetNode("/root/Game");
+ }
+
+ // 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)
+ {
+ if (t.Exception is not null)
+ GD.PrintErr(t.Exception);
+ TaskQueue.Remove(t);
+ }
+ }
+
+ private void IncomingCommand(Command command)
+ {
+ if (!Settings.IsUserAuthorized(command.User, command.IsStreamer,
+ command.IsModerator))
+ return;
+ var baseArgs = command.GetArgs();
+ var type = CommandTypeHelper.ParseCommand(baseArgs.Pop());
+ var args = baseArgs.DuplicateAtState();
+ switch (type)
+ {
+ case CommandType.MoveCard:
+ MoveCard(args);
+ break;
+ case CommandType.MoveRow:
+ MoveRow(args);
+ break;
+ case CommandType.DeleteCard:
+ DeleteCards(args);
+ break;
+ case CommandType.DeleteRow:
+ DeleteRow(args);
+ break;
+ case CommandType.CreateCard:
+ TaskQueue.Add(Task.Run(() => CreateCard(args)));
+ break;
+ case CommandType.CreateRow:
+ CreateRow(args);
+ break;
+ case CommandType.RenameCard:
+ RenameCard(args);
+ break;
+ case CommandType.RenameRow:
+ RenameRow(args);
+ break;
+ case CommandType.RecolorRow:
+ RecolorRow(args);
+ break;
+ case CommandType.ChangeCardImage:
+ TaskQueue.Add(Task.Run(() => ChangeCardImage(args)));
+ break;
+ default:
+ throw new Exception();
+ }
+ }
+
+ private void MoveCard(CommandArguments args)
+ {
+ var cardId = args.Pop();
+ var rowId = args.Pop();
+ var indexStr = args.Pop();
+ if (int.TryParse(indexStr, out int index))
+ Game.MoveCard(cardId, rowId, index);
+ else
+ Game.MoveCard(cardId, rowId, null);
+ }
+ private void MoveRow(CommandArguments args)
+ {
+ var rowId = args.Pop();
+ var newIndexStr = args.Pop();
+ var newIndex = int.Parse(newIndexStr);
+ Game.MoveRow(rowId, newIndex);
+ }
+ private void DeleteCards(CommandArguments args)
+ {
+ var ids = args.Enumerate().ToArray();
+ Game.DeleteCards(ids);
+ }
+ private void DeleteRow(CommandArguments args)
+ {
+ var rowId = args.Pop();
+ var deleteCards = args.Pop();
+ if (bool.TryParse(deleteCards, out bool del))
+ Game.DeleteRow(rowId, del);
+ else
+ Game.DeleteRow(rowId);
+ }
+ private async Task CreateCard(CommandArguments args)
+ {
+ var title = args.Pop();
+ var url = args.Pop();
+ Image img = await ImageFromUrl(url);
+ Game.CreateCard(title, img);
+ }
+ private void CreateRow(CommandArguments args)
+ {
+ var colorStr = args.Pop();
+ var title = args.Remaining();
+ var color = Color.FromString(colorStr, new Color(0, 0, 0, 0));
+ if (color == new Color(0, 0, 0, 0))
+ Game.CreateRow(null, title);
+ else
+ Game.CreateRow(color, title);
+ }
+ private void RenameCard(CommandArguments args)
+ {
+ var cardId = args.Pop();
+ var title = args.Remaining();
+ Game.RenameCard(cardId, title);
+ }
+ private void RenameRow(CommandArguments args)
+ {
+ var rowId = args.Pop();
+ var title = args.Remaining();
+ Game.RenameRow(rowId, title);
+ }
+ private void RecolorRow(CommandArguments args)
+ {
+ 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))
+ throw new Exception($"invalid color {colorStr}");
+ Game.RecolorRow(rowId, color);
+ }
+ private async Task ChangeCardImage(CommandArguments args)
+ {
+ var cardId = args.Pop();
+ var img = await ImageFromUrl(args.Pop());
+ Game.ChangeCardImage(cardId, img);
+ }
+ private async Task ImageFromUrl(string url)
+ {
+ var uri = new Uri(url);
+ var resp = await Client.GetAsync(uri);
+ if (!resp.IsSuccessStatusCode)
+ return null;
+ Image img = new();
+ var arr = await resp.Content.ReadAsByteArrayAsync();
+ //TODO detect images by header rather than extension
+ switch (Path.GetExtension(uri.AbsolutePath).ToLower())
+ {
+ case "png":
+ img.LoadPngFromBuffer(arr);
+ break;
+ case "jpg":
+ case "jpeg":
+ img.LoadJpgFromBuffer(arr);
+ break;
+ case "svg":
+ img.LoadSvgFromBuffer(arr);
+ break;
+ case "webp":
+ img.LoadWebpFromBuffer(arr);
+ break;
+ default:
+ throw new Exception("unrecognized filetype");
+ }
+ return img;
+ }
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _Client?.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+}
diff --git a/CommandParser.cs b/CommandParser.cs
new file mode 100644
index 0000000..8e10921
--- /dev/null
+++ b/CommandParser.cs
@@ -0,0 +1,44 @@
+// 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/GUI-only.build b/GUI-only.build
new file mode 100644
index 0000000..041748d
--- /dev/null
+++ b/GUI-only.build
@@ -0,0 +1,13 @@
+{
+ "disabled_build_options": {
+ "disable_2d_physics": true,
+ "disable_3d": true,
+ "disable_3d_physics": true
+ },
+ "disabled_classes": [
+ "Control",
+ "Node2D",
+ "Node3D"
+ ],
+ "type": "build_profile"
+}
\ No newline at end of file
diff --git a/PrivMsg.cs b/PrivMsg.cs
new file mode 100644
index 0000000..773533a
--- /dev/null
+++ b/PrivMsg.cs
@@ -0,0 +1,256 @@
+using Godot;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+
+public class Privmsg : TwitchChatMessage
+{
+ ///
+ /// Contains metadata related to the chat badges in the badges tag.
+ /// According to Twitch's documentation this should only include info about
+ /// subscription length, but it also contains prediction info and who knows what else.
+ ///
+ public IEnumerable BadgeInfo => TryGetTag("badge-info").Split(',');
+ ///
+ /// Contains the total number of months the user has subscribed, even if they aren't
+ /// subscribed currently.
+ ///
+ public int SubscriptionLength
+ { get
+ {
+ //TODO redo this, functional style clearly didn't work here
+ if (int.TryParse((BadgeInfo.FirstOrDefault(
+ b => b.StartsWith("SUBSCRIBER", StringComparison.CurrentCultureIgnoreCase)) ?? "")
+ .Split("/", 2).ElementAtOrDefault(1) ?? "", out int value))
+ return value;
+ return 0;
+ }
+ }
+ // ///
+ // /// List of chat badges. Most badges have only 1 version, but some badges like
+ // /// subscriber badges offer different versions of the badge depending on how
+ // /// long the user has subscribed. To get the badge, use the Get Global Chat
+ // /// Badges and Get Channel Chat Badges APIs. Match the badge to the set-id field’s
+ // /// value in the response. Then, match the version to the id field in the list of versions.
+ // ///
+ // public List Badges
+ // { get
+ // {
+ // if (!MessageTags.TryGetValue("badges", out string? value))
+ // return [];
+ // if (value == null)
+ // return [];
+ // List badges = [];
+ // foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries))
+ // {
+ // var spl = item.Split('/', 2);
+ // badges.Add(new Badge(spl[0], spl[1]));
+ // }
+ // return badges;
+ // }
+ // }
+ ///
+ /// The amount of bits cheered. Equals 0 if message did not contain a cheer.
+ ///
+ public int Bits
+ { get
+ {
+ if (!MessageTags.TryGetValue("bits", out string value))
+ return 0;
+ if (!int.TryParse(value, out int bits))
+ return 0;
+ return bits;
+ }
+ }
+ ///
+ /// The color of the user’s name in the chat room. This is a hexadecimal
+ /// RGB color code in the form, #. This tag may be empty if it is never set.
+ ///
+ public Color? Color
+ { get
+ {
+ //should have format "#RRGGBB"
+ if (!MessageTags.TryGetValue("color", out string value))
+ return null;
+ if (value.Length < 7)
+ return null;
+ int r = Convert.ToInt32(value.Substring(1, 2), 16);
+ int g = Convert.ToInt32(value.Substring(3, 2), 16);
+ int b = Convert.ToInt32(value.Substring(5, 2), 16);
+ return new Color(r / 255f, g / 255f, b / 255f, 1);
+ }
+ }
+ ///
+ /// The user’s display name. This tag may be empty if it is never set.
+ ///
+ public string DisplayName
+ { get
+ {
+ if (!MessageTags.TryGetValue("display-name", out string value))
+ return "";
+ return value ?? "";
+ }
+ }
+ // public IEnumerable Emotes
+ // { get
+ // {
+ // var tag = TryGetTag("emotes");
+ // foreach (var emote in tag.Split('/', StringSplitOptions.RemoveEmptyEntries))
+ // {
+ // var split = emote.Split(':', 2);
+ // Debug.Assert(split.Length == 2);
+ // var name = split[0];
+ // foreach (var indeces in split[1].Split(','))
+ // {
+ // var split2 = indeces.Split('-');
+ // if (!int.TryParse(split2[0], out int start) ||
+ // !int.TryParse(split2[1], out int end))
+ // throw new InvalidDataException();
+ // yield return new Emote(name, start, end - start + 1);
+ // }
+ // }
+ // }
+ // }
+ ///
+ /// An ID that uniquely identifies the message.
+ ///
+ public string Id => TryGetTag("id");
+ ///
+ /// Whether the user is a moderator in this channel
+ ///
+ public bool Moderator
+ { get
+ {
+ if (!MessageTags.TryGetValue("mod", out string value))
+ return false;
+ return value == "1";
+ }
+ }
+ ///
+ /// An ID that identifies the chat room (channel).
+ ///
+ public string RoomId => TryGetTag("room-id");
+ ///
+ /// Whether the user is subscribed to the channel
+ ///
+ public bool Subscriber
+ { get
+ {
+ if (!MessageTags.TryGetValue("subscriber", out string value))
+ return false;
+ return value == "1";
+ }
+ }
+ // public DateTime Timestamp
+ // { get
+ // {
+ // var s = TryGetTag("tmi-sent-ts");
+ // if (!double.TryParse(s, out double result))
+ // throw new InvalidDataException();
+ // return DateTime.UnixEpoch.AddSeconds(result / 1000);
+ // }
+ // }
+ ///
+ /// A Boolean value that indicates whether the user has site-wide commercial
+ /// free mode enabled
+ ///
+ public bool Turbo
+ { get
+ {
+ if (!MessageTags.TryGetValue("turbo", out string value))
+ return false;
+ return value == "1";
+ }
+ }
+ ///
+ /// The user’s ID
+ ///
+ public string UserId
+ { get
+ {
+ if (!MessageTags.TryGetValue("user-id", out string value))
+ return "";
+ return value ?? "";
+ }
+ }
+ // ///
+ // /// The type of the user. Assumes a normal user if this is not provided or is invalid.
+ // ///
+ // public UserType UserType
+ // { get
+ // {
+ // if (!MessageTags.TryGetValue("user-type", out string? value))
+ // return UserType.Normal;
+ // switch (value)
+ // {
+ // case "admin":
+ // return UserType.Admin;
+ // case "global_mod":
+ // return UserType.GlobalMod;
+ // case "staff":
+ // return UserType.Staff;
+ // default:
+ // return UserType.Normal;
+ // }
+ // }
+ // }
+ ///
+ /// A Boolean value that determines whether the user that sent the chat is a VIP.
+ ///
+ public bool Vip => MessageTags.ContainsKey("vip");
+ ///
+ /// The level of the Hype Chat. Proper values are 1-10, or 0 if not a Hype Chat.
+ ///
+ public int HypeChatLevel
+ { get
+ {
+ var value = TryGetTag("pinned-chat-paid-level");
+ switch (value.ToUpper())
+ {
+ case "ONE":
+ return 1;
+ case "TWO":
+ return 2;
+ case "THREE":
+ return 3;
+ case "FOUR":
+ return 4;
+ case "FIVE":
+ return 5;
+ case "SIX":
+ return 6;
+ case "SEVEN":
+ return 7;
+ case "EIGHT":
+ return 8;
+ case "NINE":
+ return 9;
+ case "TEN":
+ return 10;
+ default:
+ return 0;
+ }
+ }
+ }
+ ///
+ /// The ISO 4217 alphabetic currency code the user has sent the Hype Chat in.
+ ///
+ public string HypeChatCurrency => TryGetTag("pinned-chat-paid-currency");
+ public decimal? HypeChatValue
+ { get
+ {
+ var numeric = TryGetTag("pinned-chat-paid-amount");
+ var exp = TryGetTag("pinned-chat-paid-exponent");
+ if (int.TryParse(numeric, out int d_numeric) && int.TryParse(exp, out int d_exp))
+ return d_numeric / ((decimal)Math.Pow(10, d_exp));
+ return null;
+ }
+ }
+ public bool FirstMessage => TryGetTag("first-msg") == "1";
+ public string ChatMessage => Parameters.Last();
+ public Privmsg(TwitchChatMessage message) : base(message)
+ {
+
+ }
+}
\ No newline at end of file
diff --git a/Settings.cs b/Settings.cs
new file mode 100644
index 0000000..24068cb
--- /dev/null
+++ b/Settings.cs
@@ -0,0 +1,39 @@
+using System.Collections.Generic;
+using Godot;
+
+public partial class Settings : Node
+{
+ public bool AllowStreamer { get; set; }
+ public bool AllowModerators { get; set; }
+ public List UserWhitelist { get; } = new();
+ public List UserBlacklist { get; } = new();
+ public Vector2 CardSize { get; private set; }
+
+ [Signal]
+ public delegate void ChangeCardSizeEventHandler(Vector2 size);
+
+ public void SetCardSize(Vector2 size)
+ {
+ CardSize = size;
+ EmitSignal(SignalName.ChangeCardSize, size);
+ }
+ public bool IsUserAuthorized(string user, bool isStreamer = false, bool isModerator = false)
+ {
+ if (UserBlacklist.Contains(user))
+ return false;
+ if (UserWhitelist.Contains(user))
+ return true;
+ if (AllowStreamer && isStreamer)
+ return true;
+ if (AllowModerators && isModerator)
+ return true;
+ return false;
+ }
+ public void SetUserLists(IEnumerable white, IEnumerable black)
+ {
+ UserWhitelist.Clear();
+ UserWhitelist.AddRange(white);
+ UserBlacklist.Clear();
+ UserBlacklist.AddRange(black);
+ }
+}
\ No newline at end of file
diff --git a/TwitchChatMessage.cs b/TwitchChatMessage.cs
new file mode 100644
index 0000000..1704bc0
--- /dev/null
+++ b/TwitchChatMessage.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Dynamic;
+using System.Linq;
+using System.Reflection.Metadata;
+using System.Reflection.Metadata.Ecma335;
+using System.Text;
+using System.Threading.Tasks;
+public class TwitchChatMessage
+{
+ public static readonly string Delimter = "\r\n";
+ public TwitchChatMessageType MessageType { get; protected set; }
+ public string Prefix { get; protected set; }
+ public string Source { get; protected set; }
+ public List Parameters { get; } = new();
+ public string RawParameters { get; protected set; }
+ public string RawText { get; protected set; }
+ public TwitchMessageTags MessageTags { get; protected set; } = new();
+
+ protected TwitchChatMessage()
+ {
+
+ }
+ protected TwitchChatMessage(TwitchChatMessage other)
+ {
+ MessageType = other.MessageType;
+ Prefix = other.Prefix;
+ Source = other.Source;
+ Parameters = new(other.Parameters);
+ RawParameters = other.RawParameters;
+ RawText = other.RawText;
+ MessageTags = new(other.MessageTags);
+ }
+ public static TwitchChatMessage Parse(string s)
+ {
+ TwitchChatMessage message = new();
+ message.RawText = s;
+ //message has tags
+ if (s.StartsWith('@'))
+ {
+ s = s[1..];
+ //first ' ' acts as the delimeter
+ var split = s.Split(' ', 2);
+ Debug.Assert(split.Length == 2, "no space found to end tag section");
+ string tagString = split[0];
+ s = split[1].TrimStart(' ');
+ message.MessageTags = TwitchMessageTags.Parse(tagString);
+ }
+ //message has source
+ if (s.StartsWith(':'))
+ {
+ s = s[1..];
+ var split = s.Split(' ', 2);
+ Debug.Assert(split.Length == 2, "no space found to end prefix");
+ message.Prefix = split[0];
+ s = split[1].TrimStart(' ');
+ }
+ var spl_command = s.Split(' ', 2);
+ message.MessageType = TwitchChatMessageTypeHelper.Parse(spl_command[0]);
+ //message has parameters
+ if (spl_command.Length >= 2)
+ {
+ s = spl_command[1];
+ message.RawParameters = s;
+ //message has single parameter marked as the final parameter
+ //this needs to be handled specially because the leading ' '
+ //is stripped
+ if (s.StartsWith(':'))
+ {
+ message.Parameters.Add(s[1..]);
+ }
+ else
+ {
+ var spl_final = s.Split(" :", 2);
+ var spl_initial = spl_final[0].Split(' ', StringSplitOptions.RemoveEmptyEntries
+ | StringSplitOptions.TrimEntries);
+ message.Parameters.AddRange(spl_initial);
+ if (spl_final.Length >= 2)
+ message.Parameters.Add(spl_final[1]);
+ }
+ }
+ return message.MessageType switch
+ {
+ TwitchChatMessageType.PRIVMSG => new Privmsg(message),
+ _ => message,
+ };
+ }
+ protected string TryGetTag(string s)
+ {
+ if (!MessageTags.TryGetValue(s, out string value))
+ return "";
+ return value ?? "";
+ }
+ public static TwitchChatMessage MakePong(TwitchChatMessage ping)
+ {
+ var pong = new TwitchChatMessage(ping);
+ pong.MessageType = TwitchChatMessageType.PONG;
+ return pong;
+ }
+}
\ No newline at end of file
diff --git a/TwitchChatMessageType.cs b/TwitchChatMessageType.cs
new file mode 100644
index 0000000..5475da8
--- /dev/null
+++ b/TwitchChatMessageType.cs
@@ -0,0 +1,192 @@
+using System;
+
+public enum TwitchChatMessageType
+{
+ //twitch standard messages
+ JOIN = -1000,
+ NICK = -1001,
+ NOTICE = -1002,
+ PART = -1003,
+ PASS = -1004,
+ PING = -1005,
+ PONG = -1006,
+ PRIVMSG = -1007,
+ CLEARCHAT = -1008,
+ CLEARMSG = -1009,
+ GLOBALUSERSTATE = -1010,
+ HOSTTARGET = -1011,
+ RECONNECT = -1012,
+ ROOMSTATE = -1013,
+ USERNOTICE = -1014,
+ USERSTATE = -1015,
+ WHISPER = -1016,
+
+ CAP = -2000,
+
+ #region Numeric
+ RPL_WELCOME = 001,
+ RPL_YOURHOST = 002,
+ RPL_CREATED = 003,
+ RPL_MYINFO = 004,
+ RPL_ISUPPORT = 005,
+ RPL_BOUNCE = 010,
+ RPL_STATSCOMMANDS = 212,
+ RPL_ENDOFSTATS = 219,
+ RPL_UMODEIS = 221,
+ RPL_STATSUPTIME = 242,
+ RPL_LUSERCLIENT = 251,
+ RPL_LUSEROP = 252,
+ RPL_LUSERUNKNOWN = 253,
+ RPL_LUSERCHANNELS = 254,
+ RPL_LUSERME = 255,
+ RPL_ADMINME = 256,
+ RPL_ADMINLOC1 = 257,
+ RPL_ADMINLOC2 = 258,
+ RPL_ADMINEMAIL = 259,
+ RPL_TRYAGAIN = 263,
+ RPL_LOCALUSERS = 265,
+ RPL_GLOBALUSERS = 266,
+ RPL_WHOISCERTFP = 276,
+ RPL_NONE = 300,
+ RPL_AWAY = 301,
+ RPL_USERHOST = 302,
+ RPL_UNAWAY = 305,
+ RPL_NOWAWAY = 306,
+ RPL_WHOISREGNICK = 307,
+ RPL_WHOISUSER = 311,
+ RPL_WHOISSERVER = 312,
+ RPL_WHOISOPERATOR = 313,
+ RPL_WHOWASUSER = 314,
+ RPL_ENDOFWHO = 315,
+ RPL_WHOISIDLE = 317,
+ RPL_ENDOFWHOIS = 318,
+ RPL_WHOISCHANNELS = 319,
+ RPL_WHOISSPECIAL = 320,
+ RPL_LISTSTART = 321,
+ RPL_LIST = 322,
+ RPL_LISTEND = 323,
+ RPL_CHANNELMODEIS = 324,
+ RPL_CREATIONTIME = 329,
+ RPL_WHOISACCOUNT = 330,
+ RPL_NOTOPIC = 331,
+ RPL_TOPIC = 332,
+ RPL_TOPICWHOTIME = 333,
+ RPL_INVITELIST = 336,
+ RPL_ENDOFINVITELIST = 337,
+ RPL_WHOISACTUALLY = 338,
+ RPL_INVITING = 341,
+ RPL_INVEXLIST = 346,
+ RPL_ENDOFINVEXLIST = 347,
+ RPL_EXCEPTLIST = 348,
+ RPL_ENDOFEXCEPTLIST = 349,
+ RPL_VERSION = 351,
+ RPL_WHOREPLY = 352,
+ ///
+ /// Twitch seems to send this when you join a channel to list the users present
+ /// (even if documentation says it doesn't)
+ ///
+ RPL_NAMREPLY = 353,
+ RPL_LINKS = 364,
+ RPL_ENDOFLINKS = 365,
+ ///
+ /// Sent after a series of 353.
+ ///
+ RPL_ENDOFNAMES = 366,
+ RPL_BANLIST = 367,
+ RPL_ENDOFBANLIST = 368,
+ RPL_ENDOFWHOWAS = 369,
+ RPL_INFO = 371,
+ RPL_MOTD = 372,
+ RPL_ENDOFINFO = 374,
+ RPL_MOTDSTART = 375,
+ RPL_ENDOFMOTD = 376,
+ RPL_WHOISHOST = 378,
+ RPL_WHOISMODES = 379,
+ RPL_YOUREOPER = 381,
+ RPL_REHASHING = 382,
+ RPL_TIME = 391,
+ ERR_UNKNOWNERROR = 400,
+ ERR_NOSUCHNICK = 401,
+ ERR_NOSUCHSERVER = 402,
+ ERR_NOSUCHCHANNEL = 403,
+ ERR_CANNOTSENDTOCHANNEL = 404,
+ ERR_TOOMANYCHANNELS = 405,
+ ERR_WASNOSUCHNICK = 406,
+ ERR_NOORIGIN = 409,
+ ERR_NORECIPIENT = 411,
+ ERR_NOTEXTTOSEND = 412,
+ ERR_INPUTTOOLONG = 417,
+ ///
+ /// Twitch should send this if you try using an unsupported command
+ ///
+ ERR_UNKNOWNCOMMAND = 421,
+ ERR_NOMOTD = 422,
+ ERR_NONICKNAMEGIVEN = 431,
+ ERR_ERRONEUSNICKNAME = 432,
+ ERR_NICKNAMEINUSE = 433,
+ ERR_NICKCOLLISION = 436,
+ ERR_USERNOTINCHANNEL = 441,
+ ERR_NOTONCHANNEL = 442,
+ ERR_USERONCHANNEL = 443,
+ ERR_NOTREGISTERED = 451,
+ ERR_NEEDMOREPARAMS = 461,
+ ERR_ALREADYREGISTERED = 462,
+ ERR_PASSWDMISMATCH = 464,
+ ERR_YOUREBANNEDCREEP = 465,
+ ERR_CHANNELISFULL = 471,
+ ERR_UNKNOWNMODE = 472,
+ ERR_INVITEONLYCHAN = 473,
+ ERR_BANNEDFROMCHAN = 474,
+ ERR_BADCHANNELKEY = 475,
+ ERR_BADCHANMASK = 476,
+ ERR_NOPRIVILEGES = 481,
+ ERR_CHANOPRIVSNEEDED = 482,
+ ERR_CANTKILLSERVER = 483,
+ ERR_NOOPERHOST = 491,
+ ERR_UMODEUNKNOWNFLAG = 501,
+ ERR_USERSDONTMATCH = 502,
+ ERR_HELPNOTFOUND = 524,
+ ERR_INVALIDKEY = 525,
+ RPL_STARTTLS = 670,
+ RPL_WHOISSECURE = 671,
+ ERR_STARTTLS = 691,
+ ERR_INVALIDMODEPARAM = 696,
+ RPL_HELPSTART = 704,
+ RPL_HELPTXT = 705,
+ RPL_ENDOFHELP = 706,
+ RPL_NOPRIVS = 723,
+ RPL_LOGGEDIN = 900,
+ RPL_LOGGEDOUT = 901,
+ ERR_NICKLOCKED = 902,
+ RPL_SASLSUCCESS = 903,
+ ERR_SASLFAIL = 904,
+ ERR_SASLTOOLONG = 905,
+ ERR_SALSABORTED = 906,
+ ERR_SASLALREADY = 907,
+ ERR_SASLMECHS = 908,
+ #endregion //Numeric
+}
+public static class TwitchChatMessageTypeHelper
+{
+ ///
+ /// Parses a string that is either a numeric code or the command name.
+ ///
+ ///
+ ///
+ ///
+ /// The value range 000-999 is reserved for numeric commands, and will
+ /// be converted to a numeric string when forming a message.
+ ///
+ public static TwitchChatMessageType Parse(string s)
+ {
+ if (int.TryParse(s, out int result))
+ return (TwitchChatMessageType)result;
+ return Enum.Parse(s);
+ }
+ public static string ToCommand(this TwitchChatMessageType type)
+ {
+ if ((int)type >= 0 && (int)type < 1000)
+ return $"{(int)type,3}";
+ return type.ToString();
+ }
+}
\ No newline at end of file
diff --git a/TwitchChatWatcher.cs b/TwitchChatWatcher.cs
new file mode 100644
index 0000000..4173b4d
--- /dev/null
+++ b/TwitchChatWatcher.cs
@@ -0,0 +1,129 @@
+using Godot;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.WebSockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+public partial class TwitchChatWatcher : Node
+{
+ private readonly ClientWebSocket Socket = new();
+ public readonly ConcurrentQueue Queue = new();
+ private readonly CancellationTokenSource TokenSource = new();
+ public CancellationToken Token => TokenSource.Token;
+ private CommandHandler CommandHandler { get; set; }
+
+ [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");
+
+ }
+
+ // Called every frame. 'delta' is the elapsed time since the previous frame.
+ public override void _Process(double delta)
+ {
+ }
+ public async Task ConnectAsync()
+ {
+ if (Socket.State == WebSocketState.Open)
+ return;
+ await Socket.ConnectAsync(new Uri("wss://irc-ws.chat.twitch.tv:443"), Token);
+ _ = Task.Run(GetPacketsTask, Token);
+ _ = Task.Run(HandleMessages, Token);
+
+ }
+ public async Task Authenticate(string user = null, string pass = null)
+ {
+ 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 JoinChannel(string channel)
+ {
+ channel = channel.TrimStart('#');
+ await SendMessageAsync(TwitchChatMessageType.JOIN, parameters: new string[] {"#" + channel});
+ }
+ public async Task SendMessageAsync(string message)
+ {
+ await Socket.SendAsync(Encoding.UTF8.GetBytes(message),
+ WebSocketMessageType.Text, true, Token);
+ }
+ public async Task SendMessageAsync(TwitchChatMessageType command, IEnumerable parameters = null,
+ IDictionary tags = null, string prefix = null)
+ {
+ string EscapeTagValue(string s)
+ {
+ if (s is null)
+ return "";
+ return string.Join("", s.Select(c => c switch
+ {
+ ';' => @"\:",
+ ' ' => @"\s",
+ '\\' => @"\\",
+ '\r' => @"\r",
+ '\n' => @"\n",
+ char ch => ch.ToString(),
+ }));
+ }
+ var message = "";
+ if (tags is not null && tags.Count != 0)
+ {
+ message = "@" + string.Join(';',
+ tags.OrderBy(p => p.Key).Select(p => $"{p.Key}={EscapeTagValue(p.Value)}"))
+ + " ";
+ }
+ if (prefix is not null && !string.IsNullOrWhiteSpace(prefix))
+ message += ":" + prefix + " ";
+ message += command.ToCommand() + " ";
+ if (parameters is not null && parameters.Any())
+ {
+ message += string.Join(' ', parameters.SkipLast(1));
+ message += " :" + parameters.Last();
+ }
+ await SendMessageAsync(message);
+ }
+ private async Task GetPacketsTask()
+ {
+ var buffer = ArraySegment.Empty;
+ var stringData = "";
+ while (!Token.IsCancellationRequested)
+ {
+ 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);
+ }
+ }
+ private readonly ConcurrentQueue MessageStrings = new();
+ private void HandleMessages()
+ {
+ while (MessageStrings.TryDequeue(out string message))
+ {
+ var tcm = TwitchChatMessage.Parse(message);
+ if (tcm.MessageType == TwitchChatMessageType.PING)
+ _ = SendPong(tcm);
+ else if (tcm is Privmsg p)
+ {
+ EmitSignal(SignalName.IncomingCommand, new Command(p.DisplayName,
+ false, p.Moderator, p.ChatMessage));
+ }
+ }
+ }
+ private async Task SendPong(TwitchChatMessage ping)
+ {
+ var pong = TwitchChatMessage.MakePong(ping);
+ await SendMessageAsync(TwitchChatMessageType.PONG, ping.Parameters,
+ ping.MessageTags, ping.Prefix);
+ }
+}
diff --git a/TwitchMessageTags.cs b/TwitchMessageTags.cs
new file mode 100644
index 0000000..2a445a8
--- /dev/null
+++ b/TwitchMessageTags.cs
@@ -0,0 +1,209 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
+
+public class TwitchMessageTags : IDictionary
+{
+ public Dictionary Tags = new();
+ public TwitchMessageTags()
+ {
+
+ }
+ public TwitchMessageTags(TwitchMessageTags other)
+ {
+ Tags = new(other);
+ }
+ private enum ParseState
+ {
+ FindingKey,
+ FindingValue,
+ ValueEscaped,
+ }
+ //TODO this should be unit tested
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static TwitchMessageTags Parse(string s)
+ {
+ s = s.TrimStart('@');
+ TwitchMessageTags tags = new();
+ string key = "";
+ string value = "";
+ var state = ParseState.FindingKey;
+ foreach (char c in s)
+ {
+ switch (state)
+ {
+ case ParseState.FindingKey:
+ if (c == '=')
+ state = ParseState.FindingValue;
+ else if (c == ';')
+ {
+ state = ParseState.FindingKey;
+ tags.Add(key, "");
+ key = "";
+ }
+ else if (c == ' ')
+ {
+ tags.Add(key, "");
+ goto EndParse;
+ }
+ else
+ key += c;
+ break;
+ case ParseState.FindingValue:
+ if (c == '\\')
+ {
+ state = ParseState.ValueEscaped;
+ }
+ else if (c == ';')
+ {
+ tags.Add(key, value);
+ key = value = "";
+ state = ParseState.FindingKey;
+ }
+ else if (c == ' ')
+ {
+ tags.Add(key, value);
+ goto EndParse;
+ }
+ else if ("\r\n\0".Contains(c))
+ throw new ArgumentException("Invalid character in tag string", nameof(s));
+ else
+ {
+ value += c;
+ }
+ break;
+ case ParseState.ValueEscaped:
+ if (c == ':')
+ {
+ value += ';';
+ state = ParseState.FindingValue;
+ }
+ else if (c == 's')
+ {
+ value += ' ';
+ state = ParseState.FindingValue;
+ }
+ else if (c == '\\')
+ {
+ value += '\\';
+ state = ParseState.FindingValue;
+ }
+ else if (c == 'r')
+ {
+ value += '\r';
+ state = ParseState.FindingValue;
+ }
+ else if (c == 'n')
+ {
+ value += '\n';
+ state = ParseState.FindingValue;
+ }
+ else if (c == ';')
+ {
+ tags.Add(key, value);
+ key = value = "";
+ state = ParseState.FindingKey;
+ }
+ //spaces should already be stripped, but handle this as end of tags just in case
+ else if (c == ' ')
+ {
+ tags.Add(key, value);
+ key = value = "";
+ goto EndParse;
+ }
+ else if ("\r\n\0".Contains(c))
+ throw new ArgumentException("Invalid character in tag string", nameof(s));
+ else
+ {
+ value += c;
+ state = ParseState.FindingValue;
+ }
+ break;
+ default:
+ throw new InvalidEnumArgumentException("Invalid state enum");
+
+ }
+ }
+ //this is reached after processing the last character without hitting a space
+ tags.Add(key, value);
+ EndParse:
+ return tags;
+ }
+ #region IDictionary
+ public string this[string key] { get => ((IDictionary)Tags)[key]; set => ((IDictionary)Tags)[key] = value; }
+
+ public ICollection Keys => ((IDictionary)Tags).Keys;
+
+ public ICollection Values => ((IDictionary)Tags).Values;
+
+ public int Count => ((ICollection>)Tags).Count;
+
+ public bool IsReadOnly => ((ICollection>)Tags).IsReadOnly;
+
+ public void Add(string key, string value)
+ {
+ ((IDictionary)Tags).Add(key, value);
+ }
+
+ public void Add(KeyValuePair item)
+ {
+ ((ICollection>)Tags).Add(item);
+ }
+
+ public void Clear()
+ {
+ ((ICollection>)Tags).Clear();
+ }
+
+ public bool Contains(KeyValuePair item)
+ {
+ return ((ICollection>)Tags).Contains(item);
+ }
+
+ public bool ContainsKey(string key)
+ {
+ return ((IDictionary)Tags).ContainsKey(key);
+ }
+
+ public void CopyTo(KeyValuePair[] array, int arrayIndex)
+ {
+ ((ICollection>)Tags).CopyTo(array, arrayIndex);
+ }
+
+ public IEnumerator> GetEnumerator()
+ {
+ return ((IEnumerable>)Tags).GetEnumerator();
+ }
+
+ public bool Remove(string key)
+ {
+ return ((IDictionary)Tags).Remove(key);
+ }
+
+ public bool Remove(KeyValuePair item)
+ {
+ return ((ICollection>)Tags).Remove(item);
+ }
+
+ public bool TryGetValue(string key, [MaybeNullWhen(false)] out string value)
+ {
+ return ((IDictionary)Tags).TryGetValue(key, out value);
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return ((IEnumerable)Tags).GetEnumerator();
+ }
+ #endregion //IDictionary
+
+}
\ No newline at end of file
diff --git a/card.cs b/card.cs
index cbc7441..414f55d 100644
--- a/card.cs
+++ b/card.cs
@@ -3,6 +3,7 @@ using System;
public partial class card : Control
{
+ private Settings Settings => GetNode("/root/Settings");
[Export]
private string _CardName;
public string CardName
diff --git a/game.cs b/game.cs
index 37decd5..fc81d87 100644
--- a/game.cs
+++ b/game.cs
@@ -18,6 +18,10 @@ public partial class game : Control
PropogateCardSize();
}
}
+ public void SetCardSize(Vector2 size)
+ {
+ CardSize = size;
+ }
protected void PropogateCardSize()
{
foreach (var r in GetNode("%RowContainer").GetChildren().OfType())
@@ -36,7 +40,7 @@ public partial class game : Control
{
var c = card.MakeCard(GetTree());
GD.Print(c.CardId);
- c.CardName = $"Card {i}";
+ c.CardName = $"Card {c.CardId}";
if (GD.RandRange(0, 1) == 1)
{
//add to a row
@@ -49,7 +53,7 @@ public partial class game : Control
}
}
-
+
PropogateCardSize();
}
// public override void _UnhandledInput(InputEvent @event)
@@ -166,4 +170,95 @@ public partial class game : Control
}
return RemoveUnassignedCard(id);
}
+ public card FindCard(string id)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ return null;
+ foreach (var c in this.GetAllDescendents())
+ if (c.CardId == id)
+ return c;
+ return null;
+ }
+ public row FindRow(string id)
+ {
+ foreach (var r in this.GetAllDescendents())
+ if (r.RowId == id)
+ return r;
+ return null;
+ }
+
+ #region Commands
+ public void MoveCard(string cardId, string targetRowId, int? toIndex = null)
+ {
+ var r = FindRow(targetRowId);
+ if (r is null)
+ throw new Exception($"row {r.RowId} not found");
+ var c = ClaimCard(cardId);
+ if (c is null)
+ throw new Exception($"card {c.CardId} not found");
+ r.AddCard(c, toIndex);
+ }
+ public void MoveRow(string rowId, int toIndex)
+ {
+ var r = FindRow(rowId);
+ if (r is null)
+ throw new Exception($"row {r.RowId} not found");
+ //TODO what if out of range?
+ r.GetParent().MoveChild(r, toIndex);
+ }
+ public void DeleteCards(params string[] cardId)
+ {
+ //don't claim any cards unless all of them are found
+ if (!cardId.Select(FindCard).All(x => x is not null))
+ throw new Exception("not all cards found");
+ foreach (var c in cardId.Select(ClaimCard))
+ {
+ c.QueueFree();
+ }
+ }
+ public void DeleteRow(string rowId, bool deleteCards = false)
+ {
+ var r = FindRow(rowId);
+ if (r is null)
+ throw new Exception($"row {r.RowId} not found");
+ }
+ public void CreateCard(string title = null, Image image = null)
+ {
+ var scn = GD.Load("res://card.tscn");
+ var c = scn.Instantiate() as card;
+ if (!string.IsNullOrWhiteSpace(title))
+ c.CardName = title;
+ if (image is not null)
+ c.SetTexture(ImageTexture.CreateFromImage(image));
+ AddUnassignedCard(c);
+ }
+ public void CreateRow(Color? color = null, string title = null)
+ {
+ var scn = GD.Load("res://row.tscn");
+ var r = scn.Instantiate() as row;
+ if (!string.IsNullOrWhiteSpace(title))
+ r.RowText = title;
+ if (color is Color color1)
+ r.RowColor = color1;
+ }
+ public void RenameCard(string cardId, string newName)
+ {
+
+ }
+ public void RenameRow(string rowId, string newTitle)
+ {
+
+ }
+ public void RecolorRow(string rowId, Color color)
+ {
+
+ }
+ public void ChangeCardImage(string cardId, Image image)
+ {
+ var c = FindCard(cardId);
+ if (c is null)
+ throw new Exception($"card {c.CardId} not found");
+ c.SetTexture(ImageTexture.CreateFromImage(image));
+ }
+ #endregion //Commands
}
diff --git a/game.tscn b/game.tscn
index cf1da20..55ecdf3 100644
--- a/game.tscn
+++ b/game.tscn
@@ -1,12 +1,11 @@
-[gd_scene load_steps=6 format=3 uid="uid://ck0t4k3guvmfm"]
+[gd_scene load_steps=7 format=3 uid="uid://ck0t4k3guvmfm"]
[ext_resource type="PackedScene" uid="uid://b7pebyti48f7b" path="res://row.tscn" id="1_numg7"]
[ext_resource type="Script" path="res://game.cs" id="1_vl33u"]
-[ext_resource type="Script" path="res://UnassignedCardPanel.cs" id="3_dbs2t"]
[ext_resource type="Script" path="res://PictureDropHandler.cs" id="3_owd27"]
-
-[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_yj5pd"]
-bg_color = Color(0.788235, 0.788235, 0.788235, 1)
+[ext_resource type="Script" path="res://TwitchChatWatcher.cs" id="5_qurdj"]
+[ext_resource type="Script" path="res://CommandHandler.cs" id="5_yfhlo"]
+[ext_resource type="PackedScene" uid="uid://jm7tss267q8y" path="res://settings_popup.tscn" id="6_e1cou"]
[node name="Game" type="Control"]
layout_mode = 3
@@ -62,16 +61,6 @@ _RowColor = Color(0.298039, 0.87451, 0.952941, 1)
_RowText = "F"
RowId = "5"
-[node name="UnassignedCardPanel" type="PanelContainer" parent="GameContainer"]
-layout_mode = 2
-size_flags_vertical = 3
-theme_override_styles/panel = SubResource("StyleBoxFlat_yj5pd")
-script = ExtResource("3_dbs2t")
-
-[node name="UnassignedCardContainer" type="HFlowContainer" parent="GameContainer/UnassignedCardPanel"]
-unique_name_in_owner = true
-layout_mode = 2
-
[node name="PictureDropHandler" type="Control" parent="."]
unique_name_in_owner = true
anchors_preset = 0
@@ -90,3 +79,13 @@ item_1/id = 1
item_count = 1
item_0/text = ""
item_0/id = 0
+
+[node name="TwitchChatWatcher" type="Node" parent="."]
+script = ExtResource("5_qurdj")
+
+[node name="CommandHandler" type="Node" parent="."]
+script = ExtResource("5_yfhlo")
+
+[node name="SettingsPopup" parent="." instance=ExtResource("6_e1cou")]
+
+[connection signal="IncomingCommand" from="TwitchChatWatcher" to="CommandHandler" method="IncomingCommand"]
diff --git a/project.godot b/project.godot
index 32a4c4a..570aa50 100644
--- a/project.godot
+++ b/project.godot
@@ -15,6 +15,20 @@ run/main_scene="res://game.tscn"
config/features=PackedStringArray("4.2", "C#", "Forward Plus")
config/icon="res://icon.svg"
+[autoload]
+
+Settings="*res://Settings.cs"
+CommandHandler="*res://CommandHandler.cs"
+TwitchChatWatcher="*res://TwitchChatWatcher.cs"
+
[dotnet]
project/assembly_name="TierMakerControl"
+
+[input]
+
+OpenMenu={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194332,"key_label":0,"unicode":0,"echo":false,"script":null)
+]
+}
diff --git a/row.cs b/row.cs
index a6eb756..1932a10 100644
--- a/row.cs
+++ b/row.cs
@@ -6,6 +6,7 @@ using System.Linq;
public partial class row : Control
{
+ private Settings Settings => GetNode("/root/Settings");
[Export]
private Color _RowColor;
public Color RowColor
@@ -77,22 +78,6 @@ public partial class row : Control
// Called when the node enters the scene tree for the first time.
public override void _Ready()
{
- if (false)
- {
- RowColor = new Color
- {
- R = 1,
- G = 0,
- B = 0,
- A = 1,
- };
- var scene = GD.Load("res://card.tscn");
- var card = scene.Instantiate();
- //GD.Print(card);
- card.CardId = "new id";
- card.CardName = "new name";
- AddCard(card);
- }
//needs to wait until ready first
PropogateColor();
PropogateCardSize();
@@ -135,14 +120,29 @@ public partial class row : Control
throw new Exception($"Can't find card {c.CardId}");
}
}
- public void AddCard(card card)
+ public void AddCard(card card, int? toIndex = null)
{
- GetNode("%RowCardContainer").AddChild(card);
+ var n = GetNode("%RowCardContainer");
+ n.AddChild(card);
+ if (toIndex is int i)
+ {
+ n.MoveChild(card, i);
+ }
}
public card GetCard(string id)
{
//inefficient to iterate through all children
- return GetNode("%RowCardContainer").GetChildren().OfType().FirstOrDefault(c => c.CardId == id);
+ return GetNode("%RowCardContainer").GetChildren()
+ .OfType().FirstOrDefault(c => c.CardId == id);
+ }
+ public List ClaimAllCards()
+ {
+ var cs = this.GetAllDescendents().ToList();
+ foreach (var c in cs)
+ {
+ RemoveChild(c);
+ }
+ return cs;
}
public card TryRemoveCard(string id)
{
diff --git a/row.tscn b/row.tscn
index 66af743..f19ada8 100644
--- a/row.tscn
+++ b/row.tscn
@@ -19,10 +19,13 @@ outline_size = 2
outline_color = Color(0, 0, 0, 1)
[node name="Row" type="MarginContainer" groups=["RowGroup"]]
-anchors_preset = 10
-anchor_right = 1.0
+offset_right = 1152.0
grow_horizontal = 2
size_flags_vertical = 2
+theme_override_constants/margin_left = 0
+theme_override_constants/margin_top = 0
+theme_override_constants/margin_right = 0
+theme_override_constants/margin_bottom = 0
script = ExtResource("1_dodxa")
[node name="RowGrid" type="PanelContainer" parent="."]
diff --git a/settings_popup.cs b/settings_popup.cs
new file mode 100644
index 0000000..778a208
--- /dev/null
+++ b/settings_popup.cs
@@ -0,0 +1,35 @@
+using Godot;
+using System;
+using System.Linq;
+using System.Net.NetworkInformation;
+
+public partial class settings_popup : Popup
+{
+ // 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 _on_cancel_button_pressed()
+ {
+ Hide();
+ }
+ public void _on_ok_button_pressed()
+ {
+ var settings = GetNode("/root/Settings");
+ settings.AllowModerators = GetNode("%CheckBoxModerator").ButtonPressed;
+ settings.SetUserLists(GetNode("%WhiteListEdit").Text.Split('\n',
+ StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
+ GetNode("%BlackListEdit").Text.Split('\n',
+ StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
+ var tcw = GetNode("/root/TwitchChatWatcher");
+ tcw.ConnectAsync().RunSynchronously();
+ tcw.Authenticate().RunSynchronously();
+ tcw.JoinChannel(GetNode("%ChannelNameEdit").Text).RunSynchronously();
+ Hide();
+ }
+}
diff --git a/settings_popup.tscn b/settings_popup.tscn
new file mode 100644
index 0000000..6571317
--- /dev/null
+++ b/settings_popup.tscn
@@ -0,0 +1,91 @@
+[gd_scene load_steps=2 format=3 uid="uid://jm7tss267q8y"]
+
+[ext_resource type="Script" path="res://settings_popup.cs" id="1_blkox"]
+
+[node name="SettingsPopup" type="Popup"]
+title = "Settings"
+size = Vector2i(720, 480)
+visible = true
+script = ExtResource("1_blkox")
+
+[node name="SettingsPopupContainer" type="TabContainer" parent="."]
+custom_minimum_size = Vector2(720, 480)
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="ChatContainer" type="VBoxContainer" parent="SettingsPopupContainer"]
+layout_mode = 2
+
+[node name="ChannelNameContainer" type="HBoxContainer" parent="SettingsPopupContainer/ChatContainer"]
+layout_mode = 2
+
+[node name="ChannelNameLabel" type="Label" parent="SettingsPopupContainer/ChatContainer/ChannelNameContainer"]
+layout_mode = 2
+text = "Channel Name"
+
+[node name="ChannelNameEdit" type="LineEdit" parent="SettingsPopupContainer/ChatContainer/ChannelNameContainer"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(500, 0)
+layout_mode = 2
+
+[node name="CheckBoxModerator" type="CheckBox" parent="SettingsPopupContainer/ChatContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Allow moderators"
+
+[node name="UserListContainer" type="HBoxContainer" parent="SettingsPopupContainer/ChatContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="WhiteListContainer" type="VBoxContainer" parent="SettingsPopupContainer/ChatContainer/UserListContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="WhiteListLabel" type="Label" parent="SettingsPopupContainer/ChatContainer/UserListContainer/WhiteListContainer"]
+layout_mode = 2
+text = "Whitelist"
+
+[node name="WhiteListEdit" type="TextEdit" parent="SettingsPopupContainer/ChatContainer/UserListContainer/WhiteListContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="BlackListContainer" type="VBoxContainer" parent="SettingsPopupContainer/ChatContainer/UserListContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="BlackListLabel" type="Label" parent="SettingsPopupContainer/ChatContainer/UserListContainer/BlackListContainer"]
+layout_mode = 2
+text = "Blacklist
+"
+
+[node name="BlackListEdit" type="TextEdit" parent="SettingsPopupContainer/ChatContainer/UserListContainer/BlackListContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="HBoxContainer" type="HBoxContainer" parent="SettingsPopupContainer/ChatContainer"]
+layout_mode = 2
+alignment = 2
+
+[node name="CancelButton" type="Button" parent="SettingsPopupContainer/ChatContainer/HBoxContainer"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+size_flags_horizontal = 8
+text = "Cancel"
+
+[node name="OkButton" type="Button" parent="SettingsPopupContainer/ChatContainer/HBoxContainer"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+size_flags_horizontal = 8
+text = "OK"
+
+[connection signal="pressed" from="SettingsPopupContainer/ChatContainer/HBoxContainer/CancelButton" to="." method="_on_cancel_button_pressed"]
+[connection signal="pressed" from="SettingsPopupContainer/ChatContainer/HBoxContainer/OkButton" to="." method="_on_ok_button_pressed"]