From 466b5c64031a0c4dfc0bdb59b503068cd766cef9 Mon Sep 17 00:00:00 2001 From: Benjamin Palko Date: Tue, 20 May 2025 14:26:53 -0400 Subject: [PATCH 1/6] init bun and docker compose --- README.md | 36 ++++++++++++++++++++++++++++++++++++ bun.lockb | Bin 0 -> 2310 bytes docker-compose.yml | 7 +++++++ package.json | 14 ++++++++++++++ 4 files changed, 57 insertions(+) create mode 100644 README.md create mode 100755 bun.lockb create mode 100644 docker-compose.yml create mode 100644 package.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..aeff206 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Massive + + + +- [Massive](#massive) + - [Setup](#setup) - [Install run-times](#install-run-times) - [Install SpacetimeDB](#install-spacetimedb) + + +## Setup + +### Install run-times + +```bash +mise use +``` + +### Install SpacetimeDB + +```bash +curl -sSf https://install.spacetimedb.com | sh +``` + +Installs to + +``` +CLI configuration directory: /home/baobeld/.config/spacetime/ +Spacetime binary: /home/baobeld/.local/bin/spacetime +directory for installed SpacetimeDB versions: /home/baobeld/.local/share/spacetime/bin +database directory: /home/baobeld/.local/share/spacetime/data +``` + +And enable WebAssembly: + +```bash +dotnet workload install wasi-experimental +``` diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..ed29c17f4314a87dc6d8910afae59e3221bad9a3 GIT binary patch literal 2310 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6OCq1_p+IRoLZ#$A%yD;?VAS;hfW1O%)QiUG=n(+yDZLYM-E+{EJI z%(7IlI2Qv$g9DJo38Y(qG&hhw38Z;|bUu&w^MoG9i>KH$_j(&NeyM(wezrsCwbDfW zs9v?Lww9dvHpgBC&f3igR8I*2`4R+xZecJ3+6yudB*qKG!~mFnPoQFJ3iS5_^;-e; zivekVAQl2*VgStkt3bt$K>ch$T9g=#NNPZShGCF-AT~3UW&zSL08;~^ajW~s{|}^q zngD1XBLl=-WTRPjufAS2|Iycg!UZ$SrOa}se{fYgeMbMl{Sf0Vu1!Y{U76`Mzvh}F z-_B^#YOo!hRRGx=EA}e!r zuMt@O?$Dzmlctj`N0-mdY&vV*C+FXw%OazkYtd$NGWW*u690>TRgnV~=GZ|%4^n55 z7BV}aMc)}H&1F+ul&qJTS6q^qlcNVKclAPwQd8{|j0_ZtGpkbb({vO}6cTeX)ARDv zKqV6d{QD09ARZ|Ge?X1o0A+e0;Ib(-HnIci#9;;~JVEIklqM~pdOXO`3$nWcs_y|@ zf3%TVY^ep%4`y)t4N?67D(_+CIR{ftVo7Ro2?K)z&@@mD04w2{7!CD|^bCy|7+Qcb zpgI9o<})#B0YyLnR!hKY0Vc*eGd&|iJp%?Uc8CfXjT+p=skvpTMS89kC8txpX!yQSAlq7Bk*X4tAXkUt4A^eYJ6UPN-7>xkW>N9 q0V&os0*1G#o}mTEA*FdKnaP<1-GZzPXbdt}7ZMd9!ys`n_(%Xe{FBH4 literal 0 HcmV?d00001 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5842e34 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +name: Massive +services: + spacetime: + image: clockworklabs/spacetime + ports: + - 3000:3000 + command: start diff --git a/package.json b/package.json new file mode 100644 index 0000000..27d6911 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "massive", + "scripts": { + "server:up": "docker compose up -d", + "server:down": "docker compose down", + "server:build": "dotnet build server", + "server:add": "spacetime server add --url http://localhost:3000 massive --default", + "server:publish": "spacetime publish --project-path server --server massive", + "server:generate": "spacetime generate --lang csharp --out-dir client/module_bindings --project-path server" + }, + "devDependencies": { + "@types/bun": "latest" + } +} From 4e94f8d7fb55e2640472fba2b1de982bfce8da31 Mon Sep 17 00:00:00 2001 From: Benjamin Palko Date: Tue, 20 May 2025 14:27:14 -0400 Subject: [PATCH 2/6] followed docs on server setup --- README.md | 10 ++++ massive.sln | 26 +++++++++ server/.gitignore | 2 + server/Lib.cs | 116 +++++++++++++++++++++++++++++++++++++++ server/StdbModule.csproj | 14 +++++ server/global.json | 6 ++ 6 files changed, 174 insertions(+) create mode 100644 massive.sln create mode 100644 server/.gitignore create mode 100644 server/Lib.cs create mode 100644 server/StdbModule.csproj create mode 100644 server/global.json diff --git a/README.md b/README.md index aeff206..e288a13 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,13 @@ And enable WebAssembly: ```bash dotnet workload install wasi-experimental ``` + +```bash +bun server:up +``` + +Add the spacetime docker server to your cli + +```bash +spacetime server add --url http://localhost:3000 massive +``` diff --git a/massive.sln b/massive.sln new file mode 100644 index 0000000..43f9d10 --- /dev/null +++ b/massive.sln @@ -0,0 +1,26 @@ + +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}") = "StdbModule", "server\StdbModule.csproj", "{25B51793-082D-44ED-A8D2-4A87A11F1882}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BDFCBF69-C44E-4188-A9FC-2CFED6EC2246}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BDFCBF69-C44E-4188-A9FC-2CFED6EC2246}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BDFCBF69-C44E-4188-A9FC-2CFED6EC2246}.Release|Any CPU.ActiveCfg = Debug|Any CPU + {BDFCBF69-C44E-4188-A9FC-2CFED6EC2246}.Release|Any CPU.Build.0 = Debug|Any CPU + {25B51793-082D-44ED-A8D2-4A87A11F1882}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25B51793-082D-44ED-A8D2-4A87A11F1882}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25B51793-082D-44ED-A8D2-4A87A11F1882}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25B51793-082D-44ED-A8D2-4A87A11F1882}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..1746e32 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,2 @@ +bin +obj diff --git a/server/Lib.cs b/server/Lib.cs new file mode 100644 index 0000000..044257e --- /dev/null +++ b/server/Lib.cs @@ -0,0 +1,116 @@ +using SpacetimeDB; + +public static partial class Module +{ + [Table(Name = "User", Public = true)] + public partial class User + { + [PrimaryKey] + public Identity Identity; + public string? Name; + public bool Online; + } + + [Table(Name = "Message", Public = true)] + public partial class Message + { + public Identity Sender; + public Timestamp Sent; + public string Text = ""; + } + + [Reducer] + public static void SetName(ReducerContext ctx, string name) + { + name = ValidateName(name); + + var user = ctx.Db.User.Identity.Find(ctx.Sender); + if (user is not null) + { + user.Name = name; + ctx.Db.User.Identity.Update(user); + } + } + + /// Takes a name and checks if it's acceptable as a user's name. + private static string ValidateName(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new Exception("Names must not be empty"); + } + return name; + } + + [Reducer] + public static void SendMessage(ReducerContext ctx, string text) + { + text = ValidateMessage(text); + Log.Info(text); + ctx.Db.Message.Insert( + new Message + { + Sender = ctx.Sender, + Text = text, + Sent = ctx.Timestamp, + } + ); + } + + /// Takes a message's text and checks if it's acceptable to send. + private static string ValidateMessage(string text) + { + if (string.IsNullOrEmpty(text)) + { + throw new ArgumentException("Messages must not be empty"); + } + return text; + } + + [Reducer(ReducerKind.ClientConnected)] + public static void ClientConnected(ReducerContext ctx) + { + Log.Info($"Connect {ctx.Sender}"); + var user = ctx.Db.User.Identity.Find(ctx.Sender); + + if (user is not null) + { + // If this is a returning user, i.e., we already have a `User` with this `Identity`, + // set `Online: true`, but leave `Name` and `Identity` unchanged. + user.Online = true; + ctx.Db.User.Identity.Update(user); + } + else + { + // If this is a new user, create a `User` object for the `Identity`, + // which is online, but hasn't set a name. + ctx.Db.User.Insert( + new User + { + Name = null, + Identity = ctx.Sender, + Online = true, + } + ); + } + } + + [Reducer(ReducerKind.ClientDisconnected)] + public static void ClientDisconnected(ReducerContext ctx) + { + var user = ctx.Db.User.Identity.Find(ctx.Sender); + + if (user is not null) + { + // This user should exist, so set `Online: false`. + user.Online = false; + ctx.Db.User.Identity.Update(user); + } + else + { + // User does not exist, log warning + Log.Warn("Warning: No user found for disconnected client."); + } + } +} + diff --git a/server/StdbModule.csproj b/server/StdbModule.csproj new file mode 100644 index 0000000..3284863 --- /dev/null +++ b/server/StdbModule.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + wasi-wasm + enable + enable + + + + + + + diff --git a/server/global.json b/server/global.json new file mode 100644 index 0000000..4e550c1 --- /dev/null +++ b/server/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.400", + "rollForward": "latestMinor" + } +} From 6958d16bfaf34d3537e83c5a201c4b7c4a1ea44d Mon Sep 17 00:00:00 2001 From: Benjamin Palko Date: Wed, 21 May 2025 14:35:09 -0400 Subject: [PATCH 3/6] implement client side --- client/.gitignore | 4 + client/Game.cs | 22 +++++ client/Game.cs.uid | 1 + client/chat/ChatLog.cs | 20 +++++ client/chat/ChatLog.cs.uid | 1 + client/chat/ChatWindow.cs | 136 ++++++++++++++++++++++++++++++ client/chat/ChatWindow.cs.uid | 1 + client/chat/ChatWindow.tscn | 40 +++++++++ client/client.csproj | 9 ++ client/client.sln | 19 +++++ client/icon.svg | 1 + client/icon.svg.import | 37 ++++++++ client/project.godot | 26 ++++++ client/root.tscn | 9 ++ client/spacetime/Spacetime.cs | 86 +++++++++++++++++++ client/spacetime/Spacetime.cs.uid | 1 + massive.sln | 2 + 17 files changed, 415 insertions(+) create mode 100644 client/.gitignore create mode 100644 client/Game.cs create mode 100644 client/Game.cs.uid create mode 100644 client/chat/ChatLog.cs create mode 100644 client/chat/ChatLog.cs.uid create mode 100644 client/chat/ChatWindow.cs create mode 100644 client/chat/ChatWindow.cs.uid create mode 100644 client/chat/ChatWindow.tscn create mode 100644 client/client.csproj create mode 100644 client/client.sln create mode 100644 client/icon.svg create mode 100644 client/icon.svg.import create mode 100644 client/project.godot create mode 100644 client/root.tscn create mode 100644 client/spacetime/Spacetime.cs create mode 100644 client/spacetime/Spacetime.cs.uid 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/Spacetime.cs b/client/spacetime/Spacetime.cs new file mode 100644 index 0000000..6e7449a --- /dev/null +++ b/client/spacetime/Spacetime.cs @@ -0,0 +1,86 @@ +using System; +using Godot; +using SpacetimeDB; +using SpacetimeDB.Types; + +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 From 97154362ab96deb8bd3b05db0438c6f5c5d1795f Mon Sep 17 00:00:00 2001 From: Benjamin Palko Date: Thu, 22 May 2025 09:03:10 -0400 Subject: [PATCH 4/6] cleanup --- client/.editorconfig | 5 ++ client/chat/ChatInput.cs | 39 ++++++++++ client/chat/ChatInput.cs.uid | 1 + client/chat/ChatLog.cs | 93 ++++++++++++++++++---- client/chat/ChatOptions.cs | 60 +++++++++++++++ client/chat/ChatOptions.cs.uid | 1 + client/chat/ChatWindow.cs | 136 ++------------------------------- client/chat/ChatWindow.tscn | 8 +- 8 files changed, 200 insertions(+), 143 deletions(-) create mode 100644 client/.editorconfig create mode 100644 client/chat/ChatInput.cs create mode 100644 client/chat/ChatInput.cs.uid create mode 100644 client/chat/ChatOptions.cs create mode 100644 client/chat/ChatOptions.cs.uid diff --git a/client/.editorconfig b/client/.editorconfig new file mode 100644 index 0000000..0b3779e --- /dev/null +++ b/client/.editorconfig @@ -0,0 +1,5 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true diff --git a/client/chat/ChatInput.cs b/client/chat/ChatInput.cs new file mode 100644 index 0000000..652a055 --- /dev/null +++ b/client/chat/ChatInput.cs @@ -0,0 +1,39 @@ +using Godot; +using SpacetimeDB; +using SpacetimeDB.Types; + +public partial class ChatInput : LineEdit +{ + public override void _EnterTree() + { + TextSubmitted += OnMessageInput; + } + + // Called when the node enters the scene tree for the first time. + public override void _Ready() + { + DbConnection conn = Spacetime.Instance.Connection; + RegisterSubscriptions(conn); + } + + void OnMessageInput(string text) + { + Spacetime.Instance.Connection.Reducers.SendMessage(text); + Text = ""; + } + + void RegisterSubscriptions(DbConnection conn) + { + conn.Reducers.OnSendMessage += Reducer_OnSendMessageEvent; + } + + /// 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/ChatInput.cs.uid b/client/chat/ChatInput.cs.uid new file mode 100644 index 0000000..1eaa0c5 --- /dev/null +++ b/client/chat/ChatInput.cs.uid @@ -0,0 +1 @@ +uid://115ssbk8epio diff --git a/client/chat/ChatLog.cs b/client/chat/ChatLog.cs index 83a2bd7..4f55792 100644 --- a/client/chat/ChatLog.cs +++ b/client/chat/ChatLog.cs @@ -1,20 +1,87 @@ using Godot; +using SpacetimeDB; +using SpacetimeDB.Types; public partial class ChatLog : RichTextLabel { - // Called when the node enters the scene tree for the first time. - public override void _Ready() - { - } + const string SystemColor = "#747474"; - // Called every frame. 'delta' is the elapsed time since the previous frame. - public override void _Process(double delta) - { - } + public override void _EnterTree() + { + ClearLog(); + } - public void PushMessage(string name, string message, string color) - { - string entry = $"[color={color}]{name}:[/color] {message}"; - this.Text += $"{entry}\n"; - } + public override void _Ready() + { + DbConnection conn = Spacetime.Instance.Connection; + RegisterSubscriptions(conn); + } + + string UserNameOrIdentity(User user) + { + return user != null ? user.Name ?? user.Identity.ToString()[..8] : "unknown"; + } + + void PushMessage(string name, string message, string color) + { + string entry = $"[color={color}]{name}:[/color] {message}"; + this.Text += $"{entry}\n"; + } + + void ClearLog() + { + Text = ""; + } + + void RegisterSubscriptions(DbConnection conn) + { + conn.Db.User.OnInsert += User_OnInsert; + conn.Db.User.OnUpdate += User_OnUpdate; + + conn.Db.Message.OnInsert += Message_OnInsert; + } + + void User_OnInsert(EventContext ctx, User insertedValue) + { + if (ctx.Event is Event.SubscribeApplied) + { + return; + } + PushMessage("System", $"{UserNameOrIdentity(insertedValue)} connected", SystemColor); + } + + void User_OnUpdate(EventContext ctx, User oldValue, User newValue) + { + if (oldValue.Name != newValue.Name) + { + PushMessage( + "System", + $"{UserNameOrIdentity(oldValue)} renamed to {UserNameOrIdentity(newValue)}", + SystemColor + ); + } + if (oldValue.Online != newValue.Online) + { + if (newValue.Online) + { + PushMessage("System", $"{UserNameOrIdentity(newValue)} connected", SystemColor); + } + else + { + 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) + { + PushMessage(UserNameOrIdentity(sender), insertedValue.Text, color); + return; + } + PushMessage(UserNameOrIdentity(sender), insertedValue.Text, color); + } } diff --git a/client/chat/ChatOptions.cs b/client/chat/ChatOptions.cs new file mode 100644 index 0000000..041dd75 --- /dev/null +++ b/client/chat/ChatOptions.cs @@ -0,0 +1,60 @@ +using Godot; +using SpacetimeDB; +using SpacetimeDB.Types; + +public partial class ChatOptions : HBoxContainer +{ + public LineEdit Username { get; private set; } + public ColorPickerButton ColorPicker { get; private set; } + + public override void _EnterTree() + { + Username = GetNode("Username"); + ColorPicker = GetNode("ColorPicker"); + + Username.TextSubmitted += OnUsernameInput; + Username.FocusExited += ResetUsername; + } + + public override void _Ready() + { + DbConnection conn = Spacetime.Instance.Connection; + RegisterSubscriptions(conn); + } + + string UserNameOrIdentity(User user) + { + return user != null ? user.Name ?? user.Identity.ToString()[..8] : "unknown"; + } + + void ResetUsername() + { + Username.Text = UserNameOrIdentity(Spacetime.Instance.Me); + } + + void OnUsernameInput(string text) + { + Spacetime.Instance.Connection.Reducers.SetName(text); + } + + void RegisterSubscriptions(DbConnection conn) + { + conn.Reducers.OnSetName += Reducer_OnSetNameEvent; + } + + /// 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; + } + } +} diff --git a/client/chat/ChatOptions.cs.uid b/client/chat/ChatOptions.cs.uid new file mode 100644 index 0000000..e04a848 --- /dev/null +++ b/client/chat/ChatOptions.cs.uid @@ -0,0 +1 @@ +uid://tff5u6blrc1d diff --git a/client/chat/ChatWindow.cs b/client/chat/ChatWindow.cs index 0b58d20..6f387c7 100644 --- a/client/chat/ChatWindow.cs +++ b/client/chat/ChatWindow.cs @@ -1,136 +1,16 @@ using Godot; -using SpacetimeDB; -using SpacetimeDB.Types; public partial class ChatWindow : VBoxContainer { - const string SystemColor = "#747474"; + private ChatOptions _options; + private ChatLog _log; + private LineEdit _input; - 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) + // Called when the node enters the scene tree for the first time. + public override void _EnterTree() { - if (insertedValue.Identity == Spacetime.Instance.Identity) - { - ResetUsername(); - } - return; + _options = GetNode("Options"); + _log = GetNode("ChatLog"); + _input = GetNode("ChatInput"); } - _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.tscn b/client/chat/ChatWindow.tscn index be31869..05f7b26 100644 --- a/client/chat/ChatWindow.tscn +++ b/client/chat/ChatWindow.tscn @@ -1,7 +1,9 @@ -[gd_scene load_steps=3 format=3 uid="uid://cqmy41vtnqd6f"] +[gd_scene load_steps=5 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"] +[ext_resource type="Script" uid="uid://tff5u6blrc1d" path="res://chat/ChatOptions.cs" id="2_lvlsn"] +[ext_resource type="Script" uid="uid://115ssbk8epio" path="res://chat/ChatInput.cs" id="4_yd183"] [node name="Chat Window" type="VBoxContainer"] anchors_preset = 15 @@ -14,6 +16,7 @@ script = ExtResource("1_d8jvm") [node name="Options" type="HBoxContainer" parent="."] layout_mode = 2 alignment = 2 +script = ExtResource("2_lvlsn") [node name="Label" type="Label" parent="Options"] layout_mode = 2 @@ -23,7 +26,7 @@ text = "Username:" custom_minimum_size = Vector2(120, 0) layout_mode = 2 -[node name="ColorPickerButton" type="ColorPickerButton" parent="Options"] +[node name="ColorPicker" type="ColorPickerButton" parent="Options"] layout_mode = 2 text = "Color" @@ -38,3 +41,4 @@ script = ExtResource("2_fkxbv") [node name="ChatInput" type="LineEdit" parent="."] layout_mode = 2 +script = ExtResource("4_yd183") From c89ba23134fc220448c909b332950d17de5a6249 Mon Sep 17 00:00:00 2001 From: Benjamin Palko Date: Thu, 22 May 2025 14:18:36 -0400 Subject: [PATCH 5/6] uHM AKTHWUALLY --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e288a13..43168e0 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ### Install run-times ```bash -mise use +mise install ``` ### Install SpacetimeDB From 4f8fd9dc18148606b4a193802b2fefa4c689b92c Mon Sep 17 00:00:00 2001 From: Benjamin Palko Date: Thu, 22 May 2025 14:19:37 -0400 Subject: [PATCH 6/6] add color to server --- client/UserUtils.cs | 17 +++++++++++ client/UserUtils.cs.uid | 1 + client/chat/ChatLog.cs | 37 +++++++++++++---------- client/chat/ChatOptions.cs | 61 +++++++++++++++++++++++++++++++++----- server/Lib.cs | 26 +++++++++++++++- 5 files changed, 118 insertions(+), 24 deletions(-) create mode 100644 client/UserUtils.cs create mode 100644 client/UserUtils.cs.uid diff --git a/client/UserUtils.cs b/client/UserUtils.cs new file mode 100644 index 0000000..c37936f --- /dev/null +++ b/client/UserUtils.cs @@ -0,0 +1,17 @@ +using Godot; +using SpacetimeDB.Types; + +public static class UserUtils +{ + public static Color ParseColor(User user) + { + return user.Color != null && Color.HtmlIsValid(user.Color) + ? Color.FromHtml(user.Color) + : Colors.AliceBlue; + } + + public static string UserNameOrIdentity(User user) + { + return user != null ? user.Name ?? user.Identity.ToString()[..8] : "unknown"; + } +} diff --git a/client/UserUtils.cs.uid b/client/UserUtils.cs.uid new file mode 100644 index 0000000..2ced1f1 --- /dev/null +++ b/client/UserUtils.cs.uid @@ -0,0 +1 @@ +uid://dnaxnse8ipje0 diff --git a/client/chat/ChatLog.cs b/client/chat/ChatLog.cs index 4f55792..aeffd33 100644 --- a/client/chat/ChatLog.cs +++ b/client/chat/ChatLog.cs @@ -4,7 +4,7 @@ using SpacetimeDB.Types; public partial class ChatLog : RichTextLabel { - const string SystemColor = "#747474"; + private static readonly Color SystemColor = Colors.Gray; public override void _EnterTree() { @@ -17,14 +17,9 @@ public partial class ChatLog : RichTextLabel RegisterSubscriptions(conn); } - string UserNameOrIdentity(User user) + void PushMessage(string name, string message, Color color) { - return user != null ? user.Name ?? user.Identity.ToString()[..8] : "unknown"; - } - - void PushMessage(string name, string message, string color) - { - string entry = $"[color={color}]{name}:[/color] {message}"; + string entry = $"[color={color.ToHtml()}]{name}:[/color] {message}"; this.Text += $"{entry}\n"; } @@ -47,7 +42,11 @@ public partial class ChatLog : RichTextLabel { return; } - PushMessage("System", $"{UserNameOrIdentity(insertedValue)} connected", SystemColor); + PushMessage( + "System", + $"{UserUtils.UserNameOrIdentity(insertedValue)} connected", + SystemColor + ); } void User_OnUpdate(EventContext ctx, User oldValue, User newValue) @@ -56,7 +55,7 @@ public partial class ChatLog : RichTextLabel { PushMessage( "System", - $"{UserNameOrIdentity(oldValue)} renamed to {UserNameOrIdentity(newValue)}", + $"{UserUtils.UserNameOrIdentity(oldValue)} renamed to {UserUtils.UserNameOrIdentity(newValue)}", SystemColor ); } @@ -64,11 +63,19 @@ public partial class ChatLog : RichTextLabel { if (newValue.Online) { - PushMessage("System", $"{UserNameOrIdentity(newValue)} connected", SystemColor); + PushMessage( + "System", + $"{UserUtils.UserNameOrIdentity(newValue)} connected", + SystemColor + ); } else { - PushMessage("System", $"{UserNameOrIdentity(newValue)} disconnected", SystemColor); + PushMessage( + "System", + $"{UserUtils.UserNameOrIdentity(newValue)} disconnected", + SystemColor + ); } } } @@ -76,12 +83,12 @@ public partial class ChatLog : RichTextLabel 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"; + Color color = ctx.Identity == sender.Identity ? UserUtils.ParseColor(sender) : Colors.Red; if (ctx.Event is Event.SubscribeApplied) { - PushMessage(UserNameOrIdentity(sender), insertedValue.Text, color); + PushMessage(UserUtils.UserNameOrIdentity(sender), insertedValue.Text, color); return; } - PushMessage(UserNameOrIdentity(sender), insertedValue.Text, color); + PushMessage(UserUtils.UserNameOrIdentity(sender), insertedValue.Text, color); } } diff --git a/client/chat/ChatOptions.cs b/client/chat/ChatOptions.cs index 041dd75..16c4873 100644 --- a/client/chat/ChatOptions.cs +++ b/client/chat/ChatOptions.cs @@ -12,8 +12,10 @@ public partial class ChatOptions : HBoxContainer Username = GetNode("Username"); ColorPicker = GetNode("ColorPicker"); - Username.TextSubmitted += OnUsernameInput; + Username.TextSubmitted += OnUsernameChanged; Username.FocusExited += ResetUsername; + + ColorPicker.ColorChanged += OnColorChange; } public override void _Ready() @@ -22,24 +24,52 @@ public partial class ChatOptions : HBoxContainer RegisterSubscriptions(conn); } - string UserNameOrIdentity(User user) - { - return user != null ? user.Name ?? user.Identity.ToString()[..8] : "unknown"; - } - void ResetUsername() { - Username.Text = UserNameOrIdentity(Spacetime.Instance.Me); + Username.Text = UserUtils.UserNameOrIdentity(Spacetime.Instance.Me); } - void OnUsernameInput(string text) + void OnUsernameChanged(string text) { Spacetime.Instance.Connection.Reducers.SetName(text); } + void OnColorChange(Color color) + { + Spacetime.Instance.Connection.Reducers.SetColor(color.ToHtml(false)); + } + void RegisterSubscriptions(DbConnection conn) { + conn.Db.User.OnInsert += User_OnInsert; + conn.Db.User.OnUpdate += User_OnUpdate; conn.Reducers.OnSetName += Reducer_OnSetNameEvent; + conn.Reducers.OnSetColor += Reducer_OnSetColorEvent; + } + + void User_OnInsert(EventContext ctx, User insertedValue) + { + // It's me + if (ctx.Identity == insertedValue.Identity) + { + Username.Text = insertedValue.Name; + ColorPicker.Color = + insertedValue.Color != null && Color.HtmlIsValid(insertedValue.Color) + ? Color.FromHtml(insertedValue.Color) + : Colors.AliceBlue; + } + } + + void User_OnUpdate(EventContext ctx, User oldValue, User newValue) + { + if (ctx.Identity == oldValue.Identity && ctx.Identity == newValue.Identity) + { + Username.Text = newValue.Name; + ColorPicker.Color = + newValue.Color != null && Color.HtmlIsValid(newValue.Color) + ? Color.FromHtml(newValue.Color) + : Colors.AliceBlue; + } } /// Our `OnSetNameEvent` callback: print a warning if the reducer failed. @@ -57,4 +87,19 @@ public partial class ChatOptions : HBoxContainer return; } } + + void Reducer_OnSetColorEvent(ReducerEventContext ctx, string color) + { + 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 color to {color}: {error}"); + return; + } + } } diff --git a/server/Lib.cs b/server/Lib.cs index 044257e..621d1f9 100644 --- a/server/Lib.cs +++ b/server/Lib.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using SpacetimeDB; public static partial class Module @@ -8,6 +9,7 @@ public static partial class Module [PrimaryKey] public Identity Identity; public string? Name; + public string? Color; public bool Online; } @@ -42,6 +44,29 @@ public static partial class Module return name; } + [Reducer] + public static void SetColor(ReducerContext ctx, string color) + { + color = ValidateColor(color); + + var user = ctx.Db.User.Identity.Find(ctx.Sender); + if (user is not null) + { + user.Color = color; + ctx.Db.User.Identity.Update(user); + } + } + + private static string ValidateColor(string color) + { + var regex = new Regex("^([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"); + if (!regex.IsMatch(color)) + { + throw new Exception("Invalid color code"); + } + return color; + } + [Reducer] public static void SendMessage(ReducerContext ctx, string text) { @@ -113,4 +138,3 @@ public static partial class Module } } } -