mirror of
https://codeberg.org/Ikatono/TierMaker.git
synced 2025-10-28 20:45:35 -05:00
forgot to commit for a while
This commit is contained in:
146
Command.cs
Normal file
146
Command.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Remaining"/>
|
||||
/// after the previous arguments are popped.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use <see cref="DuplicateAtState"/> to safely handle subcommands.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
/// <summary>
|
||||
/// Enumerates arguments from current state, without changing state
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public IEnumerable<string> Enumerate()
|
||||
{
|
||||
var a = DuplicateAtState();
|
||||
while (true)
|
||||
{
|
||||
var s = a.Pop();
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
break;
|
||||
yield return s;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Creates a new arguments object that returns to this state when reset.
|
||||
/// Any arguments that have already been popped will not return.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
202
CommandHandler.cs
Normal file
202
CommandHandler.cs
Normal file
@@ -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<Task> 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<Settings>("/root/Settings");
|
||||
Game = GetNode<game>("/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<Image> 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);
|
||||
}
|
||||
}
|
||||
44
CommandParser.cs
Normal file
44
CommandParser.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
// using System.Collections.Generic;
|
||||
|
||||
// public static class CommandParser
|
||||
// {
|
||||
// public static List<string> SplitString(string s)
|
||||
// {
|
||||
// List<string> 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
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
13
GUI-only.build
Normal file
13
GUI-only.build
Normal file
@@ -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"
|
||||
}
|
||||
256
PrivMsg.cs
Normal file
256
PrivMsg.cs
Normal file
@@ -0,0 +1,256 @@
|
||||
using Godot;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
public class Privmsg : TwitchChatMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public IEnumerable<string> BadgeInfo => TryGetTag("badge-info").Split(',');
|
||||
/// <summary>
|
||||
/// Contains the total number of months the user has subscribed, even if they aren't
|
||||
/// subscribed currently.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
// /// <summary>
|
||||
// /// 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.
|
||||
// /// </summary>
|
||||
// public List<Badge> Badges
|
||||
// { get
|
||||
// {
|
||||
// if (!MessageTags.TryGetValue("badges", out string? value))
|
||||
// return [];
|
||||
// if (value == null)
|
||||
// return [];
|
||||
// List<Badge> badges = [];
|
||||
// foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
// {
|
||||
// var spl = item.Split('/', 2);
|
||||
// badges.Add(new Badge(spl[0], spl[1]));
|
||||
// }
|
||||
// return badges;
|
||||
// }
|
||||
// }
|
||||
/// <summary>
|
||||
/// The amount of bits cheered. Equals 0 if message did not contain a cheer.
|
||||
/// </summary>
|
||||
public int Bits
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("bits", out string value))
|
||||
return 0;
|
||||
if (!int.TryParse(value, out int bits))
|
||||
return 0;
|
||||
return bits;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// The color of the user’s name in the chat room. This is a hexadecimal
|
||||
/// RGB color code in the form, #<RGB>. This tag may be empty if it is never set.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// The user’s display name. This tag may be empty if it is never set.
|
||||
/// </summary>
|
||||
public string DisplayName
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("display-name", out string value))
|
||||
return "";
|
||||
return value ?? "";
|
||||
}
|
||||
}
|
||||
// public IEnumerable<Emote> 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);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
/// <summary>
|
||||
/// An ID that uniquely identifies the message.
|
||||
/// </summary>
|
||||
public string Id => TryGetTag("id");
|
||||
/// <summary>
|
||||
/// Whether the user is a moderator in this channel
|
||||
/// </summary>
|
||||
public bool Moderator
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("mod", out string value))
|
||||
return false;
|
||||
return value == "1";
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// An ID that identifies the chat room (channel).
|
||||
/// </summary>
|
||||
public string RoomId => TryGetTag("room-id");
|
||||
/// <summary>
|
||||
/// Whether the user is subscribed to the channel
|
||||
/// </summary>
|
||||
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);
|
||||
// }
|
||||
// }
|
||||
/// <summary>
|
||||
/// A Boolean value that indicates whether the user has site-wide commercial
|
||||
/// free mode enabled
|
||||
/// </summary>
|
||||
public bool Turbo
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("turbo", out string value))
|
||||
return false;
|
||||
return value == "1";
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// The user’s ID
|
||||
/// </summary>
|
||||
public string UserId
|
||||
{ get
|
||||
{
|
||||
if (!MessageTags.TryGetValue("user-id", out string value))
|
||||
return "";
|
||||
return value ?? "";
|
||||
}
|
||||
}
|
||||
// /// <summary>
|
||||
// /// The type of the user. Assumes a normal user if this is not provided or is invalid.
|
||||
// /// </summary>
|
||||
// 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;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
/// <summary>
|
||||
/// A Boolean value that determines whether the user that sent the chat is a VIP.
|
||||
/// </summary>
|
||||
public bool Vip => MessageTags.ContainsKey("vip");
|
||||
/// <summary>
|
||||
/// The level of the Hype Chat. Proper values are 1-10, or 0 if not a Hype Chat.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// The ISO 4217 alphabetic currency code the user has sent the Hype Chat in.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
39
Settings.cs
Normal file
39
Settings.cs
Normal file
@@ -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<string> UserWhitelist { get; } = new();
|
||||
public List<string> 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<string> white, IEnumerable<string> black)
|
||||
{
|
||||
UserWhitelist.Clear();
|
||||
UserWhitelist.AddRange(white);
|
||||
UserBlacklist.Clear();
|
||||
UserBlacklist.AddRange(black);
|
||||
}
|
||||
}
|
||||
101
TwitchChatMessage.cs
Normal file
101
TwitchChatMessage.cs
Normal file
@@ -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<string> 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;
|
||||
}
|
||||
}
|
||||
192
TwitchChatMessageType.cs
Normal file
192
TwitchChatMessageType.cs
Normal file
@@ -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,
|
||||
/// <summary>
|
||||
/// Twitch seems to send this when you join a channel to list the users present
|
||||
/// (even if documentation says it doesn't)
|
||||
/// </summary>
|
||||
RPL_NAMREPLY = 353,
|
||||
RPL_LINKS = 364,
|
||||
RPL_ENDOFLINKS = 365,
|
||||
/// <summary>
|
||||
/// Sent after a series of 353.
|
||||
/// </summary>
|
||||
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,
|
||||
/// <summary>
|
||||
/// Twitch should send this if you try using an unsupported command
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a string that is either a numeric code or the command name.
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>
|
||||
/// The value range 000-999 is reserved for numeric commands, and will
|
||||
/// be converted to a numeric string when forming a message.
|
||||
/// </remarks>
|
||||
public static TwitchChatMessageType Parse(string s)
|
||||
{
|
||||
if (int.TryParse(s, out int result))
|
||||
return (TwitchChatMessageType)result;
|
||||
return Enum.Parse<TwitchChatMessageType>(s);
|
||||
}
|
||||
public static string ToCommand(this TwitchChatMessageType type)
|
||||
{
|
||||
if ((int)type >= 0 && (int)type < 1000)
|
||||
return $"{(int)type,3}";
|
||||
return type.ToString();
|
||||
}
|
||||
}
|
||||
129
TwitchChatWatcher.cs
Normal file
129
TwitchChatWatcher.cs
Normal file
@@ -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<TwitchChatMessage> 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<CommandHandler>("/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<string> parameters = null,
|
||||
IDictionary<string, string> 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<byte>.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<string> 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);
|
||||
}
|
||||
}
|
||||
209
TwitchMessageTags.cs
Normal file
209
TwitchMessageTags.cs
Normal file
@@ -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<string, string>
|
||||
{
|
||||
public Dictionary<string, string> Tags = new();
|
||||
public TwitchMessageTags()
|
||||
{
|
||||
|
||||
}
|
||||
public TwitchMessageTags(TwitchMessageTags other)
|
||||
{
|
||||
Tags = new(other);
|
||||
}
|
||||
private enum ParseState
|
||||
{
|
||||
FindingKey,
|
||||
FindingValue,
|
||||
ValueEscaped,
|
||||
}
|
||||
//TODO this should be unit tested
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="s"></param>
|
||||
/// <returns></returns>
|
||||
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<string, string?>
|
||||
public string this[string key] { get => ((IDictionary<string, string>)Tags)[key]; set => ((IDictionary<string, string>)Tags)[key] = value; }
|
||||
|
||||
public ICollection<string> Keys => ((IDictionary<string, string>)Tags).Keys;
|
||||
|
||||
public ICollection<string> Values => ((IDictionary<string, string>)Tags).Values;
|
||||
|
||||
public int Count => ((ICollection<KeyValuePair<string, string>>)Tags).Count;
|
||||
|
||||
public bool IsReadOnly => ((ICollection<KeyValuePair<string, string>>)Tags).IsReadOnly;
|
||||
|
||||
public void Add(string key, string value)
|
||||
{
|
||||
((IDictionary<string, string>)Tags).Add(key, value);
|
||||
}
|
||||
|
||||
public void Add(KeyValuePair<string, string> item)
|
||||
{
|
||||
((ICollection<KeyValuePair<string, string>>)Tags).Add(item);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
((ICollection<KeyValuePair<string, string>>)Tags).Clear();
|
||||
}
|
||||
|
||||
public bool Contains(KeyValuePair<string, string> item)
|
||||
{
|
||||
return ((ICollection<KeyValuePair<string, string>>)Tags).Contains(item);
|
||||
}
|
||||
|
||||
public bool ContainsKey(string key)
|
||||
{
|
||||
return ((IDictionary<string, string>)Tags).ContainsKey(key);
|
||||
}
|
||||
|
||||
public void CopyTo(KeyValuePair<string, string>[] array, int arrayIndex)
|
||||
{
|
||||
((ICollection<KeyValuePair<string, string>>)Tags).CopyTo(array, arrayIndex);
|
||||
}
|
||||
|
||||
public IEnumerator<KeyValuePair<string, string>> GetEnumerator()
|
||||
{
|
||||
return ((IEnumerable<KeyValuePair<string, string>>)Tags).GetEnumerator();
|
||||
}
|
||||
|
||||
public bool Remove(string key)
|
||||
{
|
||||
return ((IDictionary<string, string>)Tags).Remove(key);
|
||||
}
|
||||
|
||||
public bool Remove(KeyValuePair<string, string> item)
|
||||
{
|
||||
return ((ICollection<KeyValuePair<string, string>>)Tags).Remove(item);
|
||||
}
|
||||
|
||||
public bool TryGetValue(string key, [MaybeNullWhen(false)] out string value)
|
||||
{
|
||||
return ((IDictionary<string, string>)Tags).TryGetValue(key, out value);
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator()
|
||||
{
|
||||
return ((IEnumerable)Tags).GetEnumerator();
|
||||
}
|
||||
#endregion //IDictionary<string, string?>
|
||||
|
||||
}
|
||||
1
card.cs
1
card.cs
@@ -3,6 +3,7 @@ using System;
|
||||
|
||||
public partial class card : Control
|
||||
{
|
||||
private Settings Settings => GetNode<Settings>("/root/Settings");
|
||||
[Export]
|
||||
private string _CardName;
|
||||
public string CardName
|
||||
|
||||
99
game.cs
99
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<row>())
|
||||
@@ -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<card>())
|
||||
if (c.CardId == id)
|
||||
return c;
|
||||
return null;
|
||||
}
|
||||
public row FindRow(string id)
|
||||
{
|
||||
foreach (var r in this.GetAllDescendents<row>())
|
||||
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<PackedScene>("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<PackedScene>("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
|
||||
}
|
||||
|
||||
29
game.tscn
29
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"]
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
|
||||
38
row.cs
38
row.cs
@@ -6,6 +6,7 @@ using System.Linq;
|
||||
|
||||
public partial class row : Control
|
||||
{
|
||||
private Settings Settings => GetNode<Settings>("/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<PackedScene>("res://card.tscn");
|
||||
var card = scene.Instantiate<card>();
|
||||
//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<card>().FirstOrDefault(c => c.CardId == id);
|
||||
return GetNode("%RowCardContainer").GetChildren()
|
||||
.OfType<card>().FirstOrDefault(c => c.CardId == id);
|
||||
}
|
||||
public List<card> ClaimAllCards()
|
||||
{
|
||||
var cs = this.GetAllDescendents<card>().ToList();
|
||||
foreach (var c in cs)
|
||||
{
|
||||
RemoveChild(c);
|
||||
}
|
||||
return cs;
|
||||
}
|
||||
public card TryRemoveCard(string id)
|
||||
{
|
||||
|
||||
7
row.tscn
7
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="."]
|
||||
|
||||
35
settings_popup.cs
Normal file
35
settings_popup.cs
Normal file
@@ -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<Settings>("/root/Settings");
|
||||
settings.AllowModerators = GetNode<CheckBox>("%CheckBoxModerator").ButtonPressed;
|
||||
settings.SetUserLists(GetNode<TextEdit>("%WhiteListEdit").Text.Split('\n',
|
||||
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
|
||||
GetNode<TextEdit>("%BlackListEdit").Text.Split('\n',
|
||||
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
|
||||
var tcw = GetNode<TwitchChatWatcher>("/root/TwitchChatWatcher");
|
||||
tcw.ConnectAsync().RunSynchronously();
|
||||
tcw.Authenticate().RunSynchronously();
|
||||
tcw.JoinChannel(GetNode<LineEdit>("%ChannelNameEdit").Text).RunSynchronously();
|
||||
Hide();
|
||||
}
|
||||
}
|
||||
91
settings_popup.tscn
Normal file
91
settings_popup.tscn
Normal file
@@ -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"]
|
||||
Reference in New Issue
Block a user