diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..90ca633 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,4 @@ +# Godot 4+ specific ignores +.godot/ +/android/ +module_bindings/ diff --git a/client/Game.cs b/client/Game.cs new file mode 100644 index 0000000..f1b6022 --- /dev/null +++ b/client/Game.cs @@ -0,0 +1,22 @@ +using Godot; + +public partial class Game : Node +{ + private Spacetime spacetime; + + public override void _EnterTree() + { + spacetime = Spacetime.Instance; + } + + // Called every frame. 'delta' is the elapsed time since the previous frame. + public override void _Process(double delta) + { + spacetime.Connection.FrameTick(); + } + + public override void _ExitTree() + { + spacetime.Connection.Disconnect(); + } +} diff --git a/client/Game.cs.uid b/client/Game.cs.uid new file mode 100644 index 0000000..3f3fc3c --- /dev/null +++ b/client/Game.cs.uid @@ -0,0 +1 @@ +uid://bg6n0cm6dds7j diff --git a/client/chat/ChatLog.cs b/client/chat/ChatLog.cs new file mode 100644 index 0000000..83a2bd7 --- /dev/null +++ b/client/chat/ChatLog.cs @@ -0,0 +1,20 @@ +using Godot; + +public partial class ChatLog : RichTextLabel +{ + // 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 PushMessage(string name, string message, string color) + { + string entry = $"[color={color}]{name}:[/color] {message}"; + this.Text += $"{entry}\n"; + } +} diff --git a/client/chat/ChatLog.cs.uid b/client/chat/ChatLog.cs.uid new file mode 100644 index 0000000..6154083 --- /dev/null +++ b/client/chat/ChatLog.cs.uid @@ -0,0 +1 @@ +uid://cgn6wa7td0ekp diff --git a/client/chat/ChatWindow.cs b/client/chat/ChatWindow.cs new file mode 100644 index 0000000..0b58d20 --- /dev/null +++ b/client/chat/ChatWindow.cs @@ -0,0 +1,136 @@ +using Godot; +using SpacetimeDB; +using SpacetimeDB.Types; + +public partial class ChatWindow : VBoxContainer +{ + const string SystemColor = "#747474"; + + private LineEdit _userNameInput; + private ChatLog _log; + private LineEdit _input; + + // Called when the node enters the scene tree for the first time. + public override void _EnterTree() + { + _userNameInput = GetNode("Options/Username"); + _log = GetNode("ChatLog"); + _input = GetNode("ChatInput"); + + _log.Text = ""; + } + + string UserNameOrIdentity(User user) + { + return user != null ? user.Name ?? user.Identity.ToString()[..8] : "unknown"; + } + + // Called when the node enters the scene tree for the first time. + public override void _Ready() + { + DbConnection conn = Spacetime.Instance.Connection; + + _userNameInput.TextSubmitted += OnUsernameInput; + _userNameInput.FocusExited += ResetUsername; + _input.TextSubmitted += OnMessageInput; + + conn.Db.User.OnInsert += User_OnInsert; + conn.Db.User.OnUpdate += User_OnUpdate; + + conn.Db.Message.OnInsert += Message_OnInsert; + + conn.Reducers.OnSetName += Reducer_OnSetNameEvent; + conn.Reducers.OnSendMessage += Reducer_OnSendMessageEvent; + } + + void ResetUsername() + { + _userNameInput.Text = UserNameOrIdentity(Spacetime.Instance.Me); + } + + // Called every frame. 'delta' is the elapsed time since the previous frame. + public override void _Process(double delta) + { + } + + private void OnUsernameInput(string text) + { + Spacetime.Instance.Connection.Reducers.SetName(text); + } + + private void OnMessageInput(string text) + { + Spacetime.Instance.Connection.Reducers.SendMessage(text); + _input.Text = ""; + } + + void User_OnInsert(EventContext ctx, User insertedValue) + { + if (ctx.Event is Event.SubscribeApplied) + { + if (insertedValue.Identity == Spacetime.Instance.Identity) + { + ResetUsername(); + } + return; + } + _log.PushMessage("System", $"{UserNameOrIdentity(insertedValue)} connected", SystemColor); + } + + void User_OnUpdate(EventContext ctx, User oldValue, User newValue) + { + if (oldValue.Name != newValue.Name) + { + _log.PushMessage("System", $"{UserNameOrIdentity(oldValue)} renamed to {UserNameOrIdentity(newValue)}", SystemColor); + } + if (oldValue.Online != newValue.Online) + { + if (newValue.Online) + { + _log.PushMessage("System", $"{UserNameOrIdentity(newValue)} connected", SystemColor); + } + else + { + _log.PushMessage("System", $"{UserNameOrIdentity(newValue)} disconnected", SystemColor); + } + } + } + + void Message_OnInsert(EventContext ctx, Message insertedValue) + { + User sender = ctx.Db.User.Identity.Find(insertedValue.Sender); + string color = sender.Identity.Equals(Spacetime.Instance.Identity) ? "blue" : "red"; + if (ctx.Event is Event.SubscribeApplied) + { + _log.PushMessage(UserNameOrIdentity(sender), insertedValue.Text, color); + return; + } + _log.PushMessage(UserNameOrIdentity(sender), insertedValue.Text, color); + } + + /// Our `OnSetNameEvent` callback: print a warning if the reducer failed. + void Reducer_OnSetNameEvent(ReducerEventContext ctx, string name) + { + var e = ctx.Event; + if (e.CallerIdentity != Spacetime.Instance.Identity) + { + // Not me + return; + } + if (e.Status is Status.Failed(var error)) + { + GD.PrintErr($"Failed to change name to {name}: {error}"); + return; + } + } + + /// Our `OnSendMessageEvent` callback: print a warning if the reducer failed. + void Reducer_OnSendMessageEvent(ReducerEventContext ctx, string text) + { + var e = ctx.Event; + if (e.CallerIdentity == Spacetime.Instance.Identity && e.Status is Status.Failed(var error)) + { + GD.PrintErr($"Failed to send message {text}: {error}"); + } + } +} diff --git a/client/chat/ChatWindow.cs.uid b/client/chat/ChatWindow.cs.uid new file mode 100644 index 0000000..e61f425 --- /dev/null +++ b/client/chat/ChatWindow.cs.uid @@ -0,0 +1 @@ +uid://c3s41dv7hv4md diff --git a/client/chat/ChatWindow.tscn b/client/chat/ChatWindow.tscn new file mode 100644 index 0000000..be31869 --- /dev/null +++ b/client/chat/ChatWindow.tscn @@ -0,0 +1,40 @@ +[gd_scene load_steps=3 format=3 uid="uid://cqmy41vtnqd6f"] + +[ext_resource type="Script" uid="uid://c3s41dv7hv4md" path="res://chat/ChatWindow.cs" id="1_d8jvm"] +[ext_resource type="Script" uid="uid://cgn6wa7td0ekp" path="res://chat/ChatLog.cs" id="2_fkxbv"] + +[node name="Chat Window" type="VBoxContainer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_d8jvm") + +[node name="Options" type="HBoxContainer" parent="."] +layout_mode = 2 +alignment = 2 + +[node name="Label" type="Label" parent="Options"] +layout_mode = 2 +text = "Username:" + +[node name="Username" type="LineEdit" parent="Options"] +custom_minimum_size = Vector2(120, 0) +layout_mode = 2 + +[node name="ColorPickerButton" type="ColorPickerButton" parent="Options"] +layout_mode = 2 +text = "Color" + +[node name="ChatLog" type="RichTextLabel" parent="."] +layout_mode = 2 +size_flags_vertical = 3 +bbcode_enabled = true +text = "BBCode [color=green]test[/color]" +scroll_following = true +vertical_alignment = 2 +script = ExtResource("2_fkxbv") + +[node name="ChatInput" type="LineEdit" parent="."] +layout_mode = 2 diff --git a/client/client.csproj b/client/client.csproj new file mode 100644 index 0000000..e45b2dc --- /dev/null +++ b/client/client.csproj @@ -0,0 +1,9 @@ + + + net8.0 + true + + + + + \ No newline at end of file diff --git a/client/client.sln b/client/client.sln new file mode 100644 index 0000000..43bc778 --- /dev/null +++ b/client/client.sln @@ -0,0 +1,19 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 2012 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "client", "client.csproj", "{CCE38DAE-1CEC-451D-8D68-43EEB50F989E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + ExportDebug|Any CPU = ExportDebug|Any CPU + ExportRelease|Any CPU = ExportRelease|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CCE38DAE-1CEC-451D-8D68-43EEB50F989E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CCE38DAE-1CEC-451D-8D68-43EEB50F989E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CCE38DAE-1CEC-451D-8D68-43EEB50F989E}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU + {CCE38DAE-1CEC-451D-8D68-43EEB50F989E}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU + {CCE38DAE-1CEC-451D-8D68-43EEB50F989E}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU + {CCE38DAE-1CEC-451D-8D68-43EEB50F989E}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU + EndGlobalSection +EndGlobal diff --git a/client/icon.svg b/client/icon.svg new file mode 100644 index 0000000..9d8b7fa --- /dev/null +++ b/client/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/icon.svg.import b/client/icon.svg.import new file mode 100644 index 0000000..c8d6c1f --- /dev/null +++ b/client/icon.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bqjxu2injm0kq" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/client/project.godot b/client/project.godot new file mode 100644 index 0000000..515c099 --- /dev/null +++ b/client/project.godot @@ -0,0 +1,26 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Massive" +run/main_scene="uid://bukwg8y6gdt3g" +config/features=PackedStringArray("4.4", "C#", "Forward Plus") +config/icon="res://icon.svg" + +[dotnet] + +project/assembly_name="client" + +[file_customization] + +folder_colors={ +"res://module_bindings/": "gray" +} diff --git a/client/root.tscn b/client/root.tscn new file mode 100644 index 0000000..98dabcd --- /dev/null +++ b/client/root.tscn @@ -0,0 +1,9 @@ +[gd_scene load_steps=3 format=3 uid="uid://bukwg8y6gdt3g"] + +[ext_resource type="PackedScene" uid="uid://cqmy41vtnqd6f" path="res://chat/ChatWindow.tscn" id="1_pq8q7"] +[ext_resource type="Script" uid="uid://bg6n0cm6dds7j" path="res://Game.cs" id="1_pyidc"] + +[node name="Root" type="Node"] +script = ExtResource("1_pyidc") + +[node name="Chat Window" parent="." instance=ExtResource("1_pq8q7")] diff --git a/client/spacetime/Program.cs b/client/spacetime/Program.cs new file mode 100644 index 0000000..71c9efd --- /dev/null +++ b/client/spacetime/Program.cs @@ -0,0 +1,123 @@ +// using SpacetimeDB; +// using SpacetimeDB.Types; +// using System; +// using System.Collections.Concurrent; +// +// // our local client SpacetimeDB identity +// Identity? local_identity = null; +// +// // declare a thread safe queue to store commands +// var input_queue = new ConcurrentQueue<(string Command, string Args)>(); +// +// void Main() +// { +// // Initialize the `AuthToken` module +// AuthToken.Init(".spacetime_csharp_quickstart"); +// // Builds and connects to the database +// DbConnection? conn = null; +// conn = ConnectToDB(); +// // Registers to run in response to database events. +// RegisterCallbacks(conn); +// // Declare a threadsafe cancel token to cancel the process loop +// var cancellationTokenSource = new CancellationTokenSource(); +// // Spawn a thread to call process updates and process commands +// var thread = new Thread(() => ProcessThread(conn, cancellationTokenSource.Token)); +// thread.Start(); +// // Handles CLI input +// InputLoop(); +// // This signals the ProcessThread to stop +// cancellationTokenSource.Cancel(); +// thread.Join(); +// } +// +// /// The URI of the SpacetimeDB instance hosting our chat database and module. +// const string HOST = "http://localhost:3000"; +// +// /// The database name we chose when we published our module. +// const string DB_NAME = "quickstart-chat"; +// +// /// Load credentials from a file and connect to the database. +// DbConnection ConnectToDB() +// { +// DbConnection? conn = null; +// conn = DbConnection.Builder() +// .WithUri(HOST) +// .WithModuleName(DB_NAME) +// .WithToken(AuthToken.Token) +// .OnConnect(OnConnected) +// .OnConnectError(OnConnectError) +// .OnDisconnect(OnDisconnected) +// .Build(); +// return conn; +// } +// +// /// Our `OnConnected` callback: save our credentials to a file. +// void OnConnected(DbConnection conn, Identity identity, string authToken) +// { +// local_identity = identity; +// AuthToken.SaveToken(authToken); +// } +// +// /// Our `OnConnectError` callback: print the error, then exit the process. +// void OnConnectError(Exception e) +// { +// Console.Write($"Error while connecting: {e}"); +// } +// +// /// Our `OnDisconnect` callback: print a note, then exit the process. +// void OnDisconnected(DbConnection conn, Exception? e) +// { +// if (e != null) +// { +// Console.Write($"Disconnected abnormally: {e}"); +// } +// else +// { +// Console.Write($"Disconnected normally."); +// } +// } +// +// /// Register all the callbacks our app will use to respond to database events. +// void RegisterCallbacks(DbConnection conn) +// { +// conn.Db.User.OnInsert += User_OnInsert; +// conn.Db.User.OnUpdate += User_OnUpdate; +// +// conn.Db.Message.OnInsert += Message_OnInsert; +// +// conn.Reducers.OnSetName += Reducer_OnSetNameEvent; +// conn.Reducers.OnSendMessage += Reducer_OnSendMessageEvent; +// } +// +// /// If the user has no set name, use the first 8 characters from their identity. +// string UserNameOrIdentity(User user) => user.Name ?? user.Identity.ToString()[..8]; +// +// /// Our `User.OnInsert` callback: if the user is online, print a notification. +// void User_OnInsert(EventContext ctx, User insertedValue) +// { +// if (insertedValue.Online) +// { +// Console.WriteLine($"{UserNameOrIdentity(insertedValue)} is online"); +// } +// } +// +// /// Our `User.OnUpdate` callback: +// /// print a notification about name and status changes. +// void User_OnUpdate(EventContext ctx, User oldValue, User newValue) +// { +// if (oldValue.Name != newValue.Name) +// { +// Console.WriteLine($"{UserNameOrIdentity(oldValue)} renamed to {newValue.Name}"); +// } +// if (oldValue.Online != newValue.Online) +// { +// if (newValue.Online) +// { +// Console.WriteLine($"{UserNameOrIdentity(newValue)} connected."); +// } +// else +// { +// Console.WriteLine($"{UserNameOrIdentity(newValue)} disconnected."); +// } +// } +// } diff --git a/client/spacetime/Program.cs.uid b/client/spacetime/Program.cs.uid new file mode 100644 index 0000000..700ee94 --- /dev/null +++ b/client/spacetime/Program.cs.uid @@ -0,0 +1 @@ +uid://dtnd8t4orjlip diff --git a/client/spacetime/Spacetime.cs b/client/spacetime/Spacetime.cs new file mode 100644 index 0000000..1fc9034 --- /dev/null +++ b/client/spacetime/Spacetime.cs @@ -0,0 +1,88 @@ +using Godot; +using SpacetimeDB; +using SpacetimeDB.Types; +using System; +using System.Linq; + +public class Spacetime +{ + /// The URI of the SpacetimeDB instance hosting our chat database and module. + const string HOST = "http://localhost:3000"; + + /// The database name we chose when we published our module. + const string DB_NAME = "massive"; + + public static Spacetime Instance + { + get + { + if (_instance == null) + { + _instance = new Spacetime(HOST, DB_NAME); + } + return _instance; + } + } + + // our local client SpacetimeDB identity + public Identity Identity { get; private set; } + public DbConnection Connection { get; private set; } + public User Me + { + get => Connection.Db.User.Identity.Find(Identity); + } + + private static Spacetime _instance; + + public Spacetime(string host, string dbName) + { + // Initialize the `AuthToken` module + AuthToken.Init(".spacetime_csharp_quickstart"); + + Connection = DbConnection.Builder() + .WithUri(host) + .WithModuleName(dbName) + .WithToken(AuthToken.Token) + .OnConnect(OnConnected) + .OnConnectError(OnConnectError) + .OnDisconnect(OnDisconnected) + .Build(); + } + + /// Our `OnConnected` callback: save our credentials to a file. + void OnConnected(DbConnection conn, Identity identity, string authToken) + { + Identity = identity; + AuthToken.SaveToken(authToken); + + conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .SubscribeToAllTables(); + } + + /// Our `OnSubscriptionApplied` callback: + /// sort all past messages and print them in timestamp order. + void OnSubscriptionApplied(SubscriptionEventContext ctx) + { + GD.Print("Connected"); + } + + /// Our `OnConnectError` callback: print the error, then exit the process. + void OnConnectError(Exception e) + { + GD.PrintErr($"Error while connecting: {e}"); + } + + /// Our `OnDisconnect` callback: print a note, then exit the process. + void OnDisconnected(DbConnection conn, Exception? e) + { + if (e != null) + { + GD.PrintErr($"Disconnected abnormally: {e}"); + } + else + { + GD.Print($"Disconnected normally."); + } + } +} diff --git a/client/spacetime/Spacetime.cs.uid b/client/spacetime/Spacetime.cs.uid new file mode 100644 index 0000000..f71b47a --- /dev/null +++ b/client/spacetime/Spacetime.cs.uid @@ -0,0 +1 @@ +uid://dug30f321xvvt diff --git a/massive.sln b/massive.sln index 43f9d10..60b24e4 100644 --- a/massive.sln +++ b/massive.sln @@ -3,6 +3,8 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "client", "client\client.csproj", "{BDFCBF69-C44E-4188-A9FC-2CFED6EC2246}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StdbModule", "server\StdbModule.csproj", "{25B51793-082D-44ED-A8D2-4A87A11F1882}" EndProject Global