diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e6dc2f7fe0..99d093a2be6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -703,6 +703,131 @@ jobs: UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + godot-testsuite: + needs: [lints] + permissions: + contents: read + runs-on: spacetimedb-new-runner-2 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/target + UseLocalBsatnRuntime: true + steps: + - name: Checkout repository + id: checkout-stdb + uses: actions/checkout@v4 + + - name: Setup dotnet + uses: actions/setup-dotnet@v3 + with: + global-json-file: global.json + + - name: Override NuGet packages + run: | + dotnet pack -c Release crates/bindings-csharp/BSATN.Runtime + dotnet pack -c Release crates/bindings-csharp/Runtime + + # Write out the nuget config file to `nuget.config`. This causes the spacetimedb-csharp-sdk repository + # to be aware of the local versions of the `bindings-csharp` packages in SpacetimeDB, and use them if + # available. Otherwise, `spacetimedb-csharp-sdk` will use the NuGet versions of the packages. + # This means that (if version numbers match) we will test the local versions of the C# packages, even + # if they're not pushed to NuGet. + # See https://learn.microsoft.com/en-us/nuget/reference/nuget-config-file for more info on the config file. + cd sdks/csharp + ./tools~/write-nuget-config.sh ../.. + + - name: Restore .NET solution + working-directory: sdks/csharp + run: dotnet restore --configfile NuGet.Config SpacetimeDB.ClientSDK.sln + + # Now, setup the Godot tests. + - name: Patch spacetimedb dependency in Cargo.toml + working-directory: demo/Blackholio/server-rust + run: | + sed -i "s|spacetimedb *=.*|spacetimedb = \{ path = \"../../../crates/bindings\" \}|" Cargo.toml + cat Cargo.toml + + - name: Install Rust toolchain + uses: dsherret/rust-toolchain-file@v1 + - name: Set default rust toolchain + run: rustup default $(rustup show active-toolchain | cut -d' ' -f1) + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: ${{ github.workspace }} + shared-key: spacetimedb + # Let the main CI job save the cache since it builds the most things + save-if: false + prefix-key: v1 + + # This step shouldn't be needed, but somehow we end up with caches that are missing librusty_v8.a. + # ChatGPT suspects that this could be due to different build invocations using the same target dir, + # and this makes sense to me because we only see it in this job where we mix `cargo build -p` with + # `cargo build --manifest-path` (which apparently build different dependency trees). + # However, we've been unable to fix it so... /shrug + - name: Check v8 outputs + run: | + find "${CARGO_TARGET_DIR}"/ -type f | grep '[/_]v8' || true + if ! [ -f "${CARGO_TARGET_DIR}"/release/gn_out/obj/librusty_v8.a ]; then + echo "Could not find v8 output file librusty_v8.a; rebuilding manually." + cargo clean --release -p v8 || true + cargo build --release -p v8 + fi + + - name: Install SpacetimeDB CLI from the local checkout + run: | + export CARGO_HOME="$HOME/.cargo" + echo "$CARGO_HOME/bin" >> "$GITHUB_PATH" + cargo install --force --path crates/cli --locked --message-format=short + cargo install --force --path crates/standalone --locked --message-format=short + # Add a handy alias using the old binary name, so that we don't have to rewrite all scripts (incl. in submodules). + ln -sf $CARGO_HOME/bin/spacetimedb-cli $CARGO_HOME/bin/spacetime + + - name: Generate client bindings + working-directory: demo/Blackholio/server-rust + run: bash ./generate.sh -y + + - name: Check for changes + run: | + tools/check-diff.sh demo/Blackholio/client-godot/module_bindings || { + echo 'Error: Godot bindings are dirty. Please run `demo/Blackholio/server-rust/generate.sh`.' + exit 1 + } + + - name: Patch SpacetimeDB Godot SDK dependency + working-directory: demo/Blackholio/client-godot + run: | + dotnet remove package SpacetimeDB.ClientSDK.Godot + dotnet add reference ../../../sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj + cat blackholio.csproj + + - name: Setup Godot + uses: chickensoft-games/setup-godot@v2 + with: + version: 4.6.2 + use-dotnet: true + + - name: Restore Godot project + working-directory: demo/Blackholio/client-godot + run: dotnet restore --configfile ../../../NuGet.Config blackholio.csproj + + - name: Build Godot project + run: godot --headless --verbose --path demo/Blackholio/client-godot --build-solutions --quit + + - name: Start SpacetimeDB + run: | + spacetime start & + disown + + - name: Publish godot-tests module to SpacetimeDB + working-directory: demo/Blackholio/server-rust + run: | + spacetime login --server-issued-login local + bash ./publish.sh + + - name: Run Godot tests + run: godot --headless --path demo/Blackholio/client-godot --scene res://tests/GodotPlayModeTests.tscn + csharp-testsuite: needs: [lints] runs-on: spacetimedb-new-runner-2 @@ -714,8 +839,6 @@ jobs: id: checkout-stdb uses: actions/checkout@v4 - # Run cheap .NET tests first. If those fail, no need to run expensive Unity tests. - - name: Setup dotnet uses: actions/setup-dotnet@v3 with: diff --git a/demo/Blackholio/client-godot/Circle2D.cs b/demo/Blackholio/client-godot/Circle2D.cs index c414b7b6843..e113065e521 100644 --- a/demo/Blackholio/client-godot/Circle2D.cs +++ b/demo/Blackholio/client-godot/Circle2D.cs @@ -1,6 +1,13 @@ +using System; using Godot; -public partial class Circle2D : Node2D +public enum CircleVisualStyle +{ + Player, + Food +} + +public abstract partial class Circle2D : Node2D { private float _radius = 10.0f; [Export] @@ -29,6 +36,68 @@ public Color Color QueueRedraw(); } } + + [Export] + public CircleVisualStyle VisualStyle { get; set; } = CircleVisualStyle.Player; + + [Export] + public float AnimationSeed { get; set; } - public override void _Draw() => DrawCircle(Vector2.Zero, Radius, Color); + public override void _Draw() + { + if (Radius <= 0.01f) return; + + switch (VisualStyle) + { + case CircleVisualStyle.Player: + DrawPlayerCircle(); + break; + case CircleVisualStyle.Food: + DrawFood(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + protected void RedrawAnimatedVisuals() => QueueRedraw(); + + private void DrawPlayerCircle() + { + var time = Time.GetTicksMsec() / 1000.0f; + var pulse = 0.5f + 0.5f * Mathf.Sin(time * 2.2f + AnimationSeed); + DrawCircle(Vector2.Zero, Radius * (1.16f + pulse * 0.04f), WithAlpha(Color, 0.14f)); + DrawCircle(Vector2.Zero, Radius, Shade(Color, 0.58f)); + DrawCircle(Vector2.Zero, Radius * 0.82f, Color); + DrawCircle(new Vector2(-Radius * 0.22f, -Radius * 0.24f), Radius * 0.34f, WithAlpha(Shade(Color, 1.42f), 0.72f)); + + var outline = new Vector2[73]; + for (var i = 0; i < outline.Length; i++) + { + var angle = Mathf.Tau * i / (outline.Length - 1); + var wave = Mathf.Sin(angle * 7.0f + time * 3.0f + AnimationSeed) * 0.035f; + outline[i] = Vector2.FromAngle(angle) * Radius * (1.015f + wave); + } + + DrawPolyline(outline, WithAlpha(Shade(Color, 1.55f), 0.88f), Mathf.Clamp(Radius * 0.085f, 1.5f, 5.0f), true); + } + + private void DrawFood() + { + var time = Time.GetTicksMsec() / 1000.0f; + var pulse = 0.5f + 0.5f * Mathf.Sin(time * 5.0f + AnimationSeed); + DrawCircle(Vector2.Zero, Radius * (1.32f + pulse * 0.09f), WithAlpha(Color, 0.1f)); + DrawCircle(Vector2.Zero, Radius, Shade(Color, 0.72f)); + DrawCircle(Vector2.Zero, Radius * 0.64f, Color); + DrawCircle(Vector2.Zero, Radius * 0.24f, WithAlpha(Shade(Color, 1.55f), 0.86f)); + } + + private static Color Shade(Color color, float multiplier) => new Color( + Mathf.Clamp(color.R * multiplier, 0.0f, 1.0f), + Mathf.Clamp(color.G * multiplier, 0.0f, 1.0f), + Mathf.Clamp(color.B * multiplier, 0.0f, 1.0f), + color.A + ); + + private static Color WithAlpha(Color color, float alpha) => new(color.R, color.G, color.B, alpha); } diff --git a/demo/Blackholio/client-godot/CircleController.cs b/demo/Blackholio/client-godot/CircleController.cs index 73504709042..4f0baf22ff2 100644 --- a/demo/Blackholio/client-godot/CircleController.cs +++ b/demo/Blackholio/client-godot/CircleController.cs @@ -69,8 +69,14 @@ private Label Label { Name = $"{Name}_Label", TopLevel = false, - MouseFilter = Control.MouseFilterEnum.Ignore + MouseFilter = Control.MouseFilterEnum.Ignore, + HorizontalAlignment = HorizontalAlignment.Center }; + _label.AddThemeFontSizeOverride("font_size", 13); + _label.AddThemeColorOverride("font_color", Colors.White); + _label.AddThemeColorOverride("font_shadow_color", new Color(0, 0, 0, 0.75f)); + _label.AddThemeConstantOverride("shadow_offset_x", 1); + _label.AddThemeConstantOverride("shadow_offset_y", 1); LabelRoot.AddChild(_label); } return _label; @@ -90,6 +96,7 @@ public CircleController(Circle circle, PlayerController ownerPlayer) : base(circ public override void _Process(double delta) { base._Process(delta); + Label.Text = OwnerPlayer?.Username ?? ""; UpdateScreenLabelPosition(); } @@ -105,8 +112,20 @@ public override void OnDelete() OwnerPlayer?.OnCircleDeleted(this); } + public override void OnConsumed() + { + if (IsInstanceValid(Label)) + { + Label.QueueFree(); + } + + OwnerPlayer?.OnCircleDeleted(this); + } + private void UpdateScreenLabelPosition() { + if (!IsInstanceValid(Label)) return; + Label.Size = Label.GetCombinedMinimumSize(); var screenPosition = GetGlobalTransformWithCanvas().Origin; var offset = new Vector2(0.0f, Radius + 8.0f); diff --git a/demo/Blackholio/client-godot/EntityController.cs b/demo/Blackholio/client-godot/EntityController.cs index 207354f0489..a94ba3667d6 100644 --- a/demo/Blackholio/client-godot/EntityController.cs +++ b/demo/Blackholio/client-godot/EntityController.cs @@ -4,6 +4,7 @@ public abstract partial class EntityController : Circle2D { private const float LerpDurationSec = 0.1f; + private const float DespawnDurationSec = 0.2f; public int EntityId { get; private set; } @@ -11,11 +12,17 @@ public abstract partial class EntityController : Circle2D private Vector2 LerpStartPosition { get; set; } private Vector2 TargetPosition { get; set; } private float TargetRadius { get; set; } + private bool IsDespawning { get; set; } + private float DespawnTime { get; set; } + private Vector2 DespawnStartPosition { get; set; } + private float DespawnStartRadius { get; set; } + private Node2D DespawnTarget { get; set; } protected EntityController(int entityId, Color color) { EntityId = entityId; Color = color; + AnimationSeed = entityId * 0.73f; var entity = GameManager.Conn.Db.Entity.EntityId.Find(entityId); var position = (Vector2)entity.Position; @@ -28,6 +35,8 @@ protected EntityController(int entityId, Color color) public void OnEntityUpdated(Entity newRow) { + if (IsDespawning) return; + LerpTime = 0.0f; LerpStartPosition = GlobalPosition; TargetPosition = (Vector2)newRow.Position; @@ -35,13 +44,42 @@ public void OnEntityUpdated(Entity newRow) } public virtual void OnDelete() => QueueFree(); + public virtual void OnConsumed() { } + + public void StartDespawn(Node2D target) + { + IsDespawning = true; + DespawnTime = 0.0f; + DespawnStartPosition = GlobalPosition; + DespawnStartRadius = Radius; + DespawnTarget = target; + ZIndex += 10; + } public override void _Process(double delta) { var frameDelta = (float)delta; + if (IsDespawning) + { + DespawnTime = Mathf.Min(DespawnTime + frameDelta, DespawnDurationSec); + var t = DespawnTime / DespawnDurationSec; + var targetPosition = IsInstanceValid(DespawnTarget) ? DespawnTarget.GlobalPosition : TargetPosition; + GlobalPosition = DespawnStartPosition.Lerp(targetPosition, t); + Radius = Mathf.Lerp(DespawnStartRadius, 0.0f, t); + RedrawAnimatedVisuals(); + + if (DespawnTime >= DespawnDurationSec) + { + QueueFree(); + } + + return; + } + LerpTime = Mathf.Min(LerpTime + frameDelta, LerpDurationSec); GlobalPosition = LerpStartPosition.Lerp(TargetPosition, LerpTime / LerpDurationSec); Radius = Mathf.Lerp(Radius, TargetRadius, frameDelta * 8.0f); + RedrawAnimatedVisuals(); } private static float MassToRadius(int mass) => Mathf.Sqrt(mass); diff --git a/demo/Blackholio/client-godot/FoodController.cs b/demo/Blackholio/client-godot/FoodController.cs index fc46ba0dbca..c5b115bb81a 100644 --- a/demo/Blackholio/client-godot/FoodController.cs +++ b/demo/Blackholio/client-godot/FoodController.cs @@ -13,5 +13,8 @@ public partial class FoodController : EntityController new(35 / 255.0f, 245 / 255.0f, 165 / 255.0f), ]; - public FoodController(Food food) : base(food.EntityId, ColorPalette[food.EntityId % ColorPalette.Length]) { } -} \ No newline at end of file + public FoodController(Food food) : base(food.EntityId, ColorPalette[food.EntityId % ColorPalette.Length]) + { + VisualStyle = CircleVisualStyle.Food; + } +} diff --git a/demo/Blackholio/client-godot/GameManager.cs b/demo/Blackholio/client-godot/GameManager.cs index b22f8546b4f..47f849453f8 100644 --- a/demo/Blackholio/client-godot/GameManager.cs +++ b/demo/Blackholio/client-godot/GameManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using SpacetimeDB; using SpacetimeDB.Types; using Godot; @@ -16,7 +17,7 @@ public partial class GameManager : Node private string DatabaseName { get; set; } = "blackholio"; [Export] - private Color BackgroundColor { get; set; } = Colors.MidnightBlue; + private Color BackgroundColor { get; set; } = new(0.006f, 0.009f, 0.024f); [Export] private float BorderThickness { get; set; } = 5.0f; @@ -31,6 +32,8 @@ public partial class GameManager : Node public static Identity LocalIdentity { get; private set; } public static DbConnection Conn { get; private set; } + private HudController Hud { get; set; } + public GameManager() { var builder = DbConnection.Builder() @@ -46,12 +49,15 @@ public GameManager() } Conn = builder.Build(); + Conn.OnUnhandledReducerError += HandleUnhandledReducerError; STDBUpdateManager.Add(Conn); } public override void _EnterTree() { Instance = this; + Hud = new HudController(DefaultPlayerName); + AddChild(Hud); } public override void _ExitTree() @@ -68,6 +74,12 @@ public override void _ExitTree() private void Disconnect() { + if (Conn != null) + { + Conn.OnUnhandledReducerError -= HandleUnhandledReducerError; + Conn.Db.Player.OnUpdate -= HideUsernameChooserAfterNameUpdate; + } + STDBUpdateManager.Remove(Conn, true); Conn = null; } @@ -112,37 +124,56 @@ private void HandleSubscriptionApplied(SubscriptionEventContext ctx) // Get the world size from the config table and set up the arena var worldSize = Conn.Db.Config.Id.Find(0).WorldSize; SetupArena(worldSize); - - ctx.Reducers.EnterGame(DefaultPlayerName); + + var player = ctx.Db.Player.Identity.Find(LocalIdentity); + if (player == null || string.IsNullOrEmpty(player.Name)) + { + HudController.Instance?.ShowUsernameChooser(true); + Conn.Db.Player.OnUpdate += HideUsernameChooserAfterNameUpdate; + return; + } + + HudController.Instance?.ShowUsernameChooser(false); + if (!ctx.Db.Circle.PlayerId.Filter(player.PlayerId).Any()) + { + ctx.Reducers.EnterGame(player.Name); + } + } + + private static void HideUsernameChooserAfterNameUpdate(EventContext context, Player oldPlayer, Player newPlayer) + { + if (newPlayer.Identity != LocalIdentity || string.IsNullOrEmpty(newPlayer.Name)) return; + + HudController.Instance?.ShowUsernameChooser(false); + Conn.Db.Player.OnUpdate -= HideUsernameChooserAfterNameUpdate; + } + + private static void HandleUnhandledReducerError(ReducerEventContext context, Exception ex) + { + GD.PrintErr($"Reducer error: {ex.Message}"); } private void SetupArena(float worldSize) { - var polygon = new[] - { - new Vector2(0, 0), - new Vector2(worldSize, 0), - new Vector2(worldSize, worldSize), - new Vector2(0, worldSize), - }; - var background = new Polygon2D - { - Name = "Background", - Color = BackgroundColor, - Position = Vector2.Zero, - Polygon = polygon, - ZIndex = -1000 - }; - background.AddChild(new Polygon2D + AddChild(new StarfieldBackground(worldSize, BackgroundColor), @internal: InternalMode.Back); + + var border = new Polygon2D { - Name = "Border", + Name = "Arena Border", Color = BorderColor, Position = Vector2.Zero, InvertEnabled = true, InvertBorder = BorderThickness, - Polygon = polygon - }); - AddChild(background); + Polygon = new[] + { + new Vector2(0, 0), + new Vector2(worldSize, 0), + new Vector2(worldSize, worldSize), + new Vector2(0, worldSize), + }, + ZIndex = -500 + }; + AddChild(border); AddChild(new CameraController(worldSize)); } diff --git a/demo/Blackholio/client-godot/HudController.cs b/demo/Blackholio/client-godot/HudController.cs new file mode 100644 index 00000000000..6150fcab441 --- /dev/null +++ b/demo/Blackholio/client-godot/HudController.cs @@ -0,0 +1,342 @@ +using System.Collections.Generic; +using System.Linq; +using Godot; + +public partial class HudController : CanvasLayer +{ + private const int MaxLeaderboardRows = 11; + + private readonly string _defaultUsername; + private readonly List _leaderboardRows = new(); + + private Label _massLabel; + private Label _circlesLabel; + private Control _usernameOverlay; + private LineEdit _usernameInput; + private Control _deathOverlay; + + public static HudController Instance { get; private set; } + + public HudController(string defaultUsername) + { + _defaultUsername = defaultUsername; + Layer = 32; + Name = "HUD"; + } + + public override void _EnterTree() + { + Instance = this; + } + + public override void _ExitTree() + { + if (Instance == this) + { + Instance = null; + } + } + + public override void _Ready() + { + var root = new Control + { + Name = "HUDRoot", + MouseFilter = Control.MouseFilterEnum.Ignore + }; + root.SetAnchorsPreset(Control.LayoutPreset.FullRect); + AddChild(root); + + BuildStatusPanel(root); + BuildLeaderboard(root); + BuildUsernameChooser(root); + BuildDeathOverlay(root); + } + + public override void _Process(double delta) + { + UpdateStatus(); + UpdateLeaderboard(); + } + + public void ShowUsernameChooser(bool visible) + { + if (_usernameOverlay == null) return; + + _usernameOverlay.Visible = visible; + if (visible) + { + _usernameInput.Text = _defaultUsername; + _usernameInput.SelectAll(); + _usernameInput.GrabFocus(); + } + } + + public void ShowDeathScreen(bool visible) + { + if (_deathOverlay == null) return; + + _deathOverlay.Visible = visible; + } + + public void SubmitUsernameForTests(string username) + { + if (_usernameInput == null) return; + + _usernameInput.Text = username; + SubmitUsername(); + } + + private void BuildStatusPanel(Control root) + { + var panel = CreatePanel("StatusPanel", new Color(0.025f, 0.035f, 0.07f, 0.78f)); + panel.SetAnchorsPreset(Control.LayoutPreset.TopLeft); + panel.OffsetLeft = 16; + panel.OffsetTop = 16; + panel.OffsetRight = 230; + panel.OffsetBottom = 92; + root.AddChild(panel); + + var box = new VBoxContainer(); + box.AddThemeConstantOverride("separation", 4); + panel.AddChild(box); + + _massLabel = CreateLabel("Mass: 0", 18, Colors.White); + _circlesLabel = CreateLabel("Circles: 0", 14, new Color(0.78f, 0.84f, 0.94f)); + box.AddChild(_massLabel); + box.AddChild(_circlesLabel); + } + + private void BuildLeaderboard(Control root) + { + var panel = CreatePanel("Leaderboard", new Color(0.025f, 0.035f, 0.07f, 0.82f)); + panel.AnchorLeft = 1; + panel.AnchorRight = 1; + panel.OffsetLeft = -284; + panel.OffsetTop = 16; + panel.OffsetRight = -16; + panel.OffsetBottom = 374; + root.AddChild(panel); + + var box = new VBoxContainer(); + box.AddThemeConstantOverride("separation", 5); + panel.AddChild(box); + + var title = CreateLabel("Leaderboard", 18, Colors.White); + box.AddChild(title); + + for (var i = 0; i < MaxLeaderboardRows; i++) + { + var row = new HBoxContainer + { + Visible = false, + CustomMinimumSize = new Vector2(0, 22) + }; + row.AddThemeConstantOverride("separation", 8); + + var rank = CreateLabel("", 13, new Color(0.6f, 0.72f, 0.92f)); + rank.CustomMinimumSize = new Vector2(28, 0); + var username = CreateLabel("", 13, Colors.White); + username.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + var mass = CreateLabel("", 13, new Color(0.7f, 1.0f, 0.78f)); + mass.HorizontalAlignment = HorizontalAlignment.Right; + mass.CustomMinimumSize = new Vector2(54, 0); + + row.AddChild(rank); + row.AddChild(username); + row.AddChild(mass); + box.AddChild(row); + _leaderboardRows.Add(new LeaderboardRowControls(row, rank, username, mass)); + } + } + + private void BuildUsernameChooser(Control root) + { + _usernameOverlay = CreateModalOverlay("UsernameOverlay"); + root.AddChild(_usernameOverlay); + + var center = new CenterContainer(); + center.SetAnchorsPreset(Control.LayoutPreset.FullRect); + _usernameOverlay.AddChild(center); + + var panel = CreatePanel("UsernamePanel", new Color(0.04f, 0.055f, 0.1f, 0.96f)); + panel.CustomMinimumSize = new Vector2(380, 188); + center.AddChild(panel); + + var box = new VBoxContainer(); + box.AddThemeConstantOverride("separation", 12); + panel.AddChild(box); + + var title = CreateLabel("Choose Username", 24, Colors.White); + title.HorizontalAlignment = HorizontalAlignment.Center; + box.AddChild(title); + + _usernameInput = new LineEdit + { + Text = _defaultUsername, + PlaceholderText = "Username", + MaxLength = 24 + }; + _usernameInput.TextSubmitted += _ => SubmitUsername(); + box.AddChild(_usernameInput); + + var button = new Button + { + Text = "Play" + }; + button.Pressed += SubmitUsername; + box.AddChild(button); + } + + private void BuildDeathOverlay(Control root) + { + _deathOverlay = CreateModalOverlay("DeathOverlay"); + _deathOverlay.Visible = false; + root.AddChild(_deathOverlay); + + var center = new CenterContainer(); + center.SetAnchorsPreset(Control.LayoutPreset.FullRect); + _deathOverlay.AddChild(center); + + var panel = CreatePanel("DeathPanel", new Color(0.04f, 0.055f, 0.1f, 0.96f)); + panel.CustomMinimumSize = new Vector2(320, 148); + center.AddChild(panel); + + var box = new VBoxContainer(); + box.AddThemeConstantOverride("separation", 14); + panel.AddChild(box); + + var title = CreateLabel("Consumed", 24, Colors.White); + title.HorizontalAlignment = HorizontalAlignment.Center; + box.AddChild(title); + + var button = new Button + { + Text = "Respawn" + }; + button.Pressed += Respawn; + box.AddChild(button); + } + + private void SubmitUsername() + { + if (!GameManager.IsConnected()) return; + + var name = _usernameInput.Text.Trim(); + if (string.IsNullOrEmpty(name)) + { + name = ""; + } + + GameManager.Conn.Reducers.EnterGame(name); + ShowUsernameChooser(false); + } + + private void Respawn() + { + if (!GameManager.IsConnected()) return; + + GameManager.Conn.Reducers.Respawn(); + ShowDeathScreen(false); + } + + private void UpdateStatus() + { + var local = PlayerController.Local; + var mass = local?.TotalMass() ?? 0; + var circleCount = local?.NumberOfOwnedCircles ?? 0; + _massLabel.Text = $"Mass: {mass}"; + _circlesLabel.Text = $"Circles: {circleCount}"; + } + + private void UpdateLeaderboard() + { + var players = Instantiator.PlayerControllers.Values + .Select(player => (player, mass: player.TotalMass())) + .Where(entry => entry.mass > 0) + .OrderByDescending(entry => entry.mass) + .Take(10) + .ToList(); + + var localPlayer = PlayerController.Local; + if (localPlayer != null && localPlayer.NumberOfOwnedCircles > 0 && players.All(entry => entry.player != localPlayer)) + { + players.Add((localPlayer, localPlayer.TotalMass())); + } + + var rowIndex = 0; + for (; rowIndex < players.Count && rowIndex < _leaderboardRows.Count; rowIndex++) + { + var row = _leaderboardRows[rowIndex]; + var player = players[rowIndex].player; + var isLocal = player == localPlayer; + row.Root.Visible = true; + row.Rank.Text = $"{rowIndex + 1}."; + row.Username.Text = player.Username; + row.Mass.Text = players[rowIndex].mass.ToString(); + row.Username.AddThemeColorOverride("font_color", isLocal ? new Color(0.72f, 1.0f, 0.86f) : Colors.White); + } + + for (; rowIndex < _leaderboardRows.Count; rowIndex++) + { + _leaderboardRows[rowIndex].Root.Visible = false; + } + } + + private static Control CreateModalOverlay(string name) + { + var overlay = new ColorRect + { + Name = name, + Color = new Color(0.0f, 0.0f, 0.0f, 0.58f), + MouseFilter = Control.MouseFilterEnum.Stop + }; + overlay.SetAnchorsPreset(Control.LayoutPreset.FullRect); + return overlay; + } + + private static PanelContainer CreatePanel(string name, Color background) + { + var panel = new PanelContainer + { + Name = name, + MouseFilter = Control.MouseFilterEnum.Stop + }; + + var style = new StyleBoxFlat + { + BgColor = background, + BorderColor = new Color(0.25f, 0.46f, 0.72f, 0.55f), + ContentMarginLeft = 14, + ContentMarginTop = 12, + ContentMarginRight = 14, + ContentMarginBottom = 12 + }; + style.SetBorderWidthAll(1); + style.SetCornerRadiusAll(8); + panel.AddThemeStyleboxOverride("panel", style); + return panel; + } + + private static Label CreateLabel(string text, int fontSize, Color color) + { + var label = new Label + { + Text = text, + ClipText = true + }; + label.AddThemeFontSizeOverride("font_size", fontSize); + label.AddThemeColorOverride("font_color", color); + label.AddThemeColorOverride("font_shadow_color", new Color(0, 0, 0, 0.55f)); + label.AddThemeConstantOverride("shadow_offset_x", 1); + label.AddThemeConstantOverride("shadow_offset_y", 1); + return label; + } + + private readonly record struct LeaderboardRowControls( + HBoxContainer Root, + Label Rank, + Label Username, + Label Mass + ); +} diff --git a/demo/Blackholio/client-godot/HudController.cs.uid b/demo/Blackholio/client-godot/HudController.cs.uid new file mode 100644 index 00000000000..f3b71e44b21 --- /dev/null +++ b/demo/Blackholio/client-godot/HudController.cs.uid @@ -0,0 +1 @@ +uid://hbxe4cwg38rt diff --git a/demo/Blackholio/client-godot/Instantiator.cs b/demo/Blackholio/client-godot/Instantiator.cs index 682a0ed0500..5d2fff6759b 100644 --- a/demo/Blackholio/client-godot/Instantiator.cs +++ b/demo/Blackholio/client-godot/Instantiator.cs @@ -15,6 +15,7 @@ private DbConnection Conn if (_conn != null) { _conn.Db.Circle.OnInsert -= CircleOnInsert; + _conn.Db.ConsumeEntityEvent.OnInsert -= ConsumeEntityEventOnInsert; _conn.Db.Entity.OnUpdate -= EntityOnUpdate; _conn.Db.Entity.OnDelete -= EntityOnDelete; _conn.Db.Food.OnInsert -= FoodOnInsert; @@ -27,6 +28,7 @@ private DbConnection Conn if (value != null) { value.Db.Circle.OnInsert += CircleOnInsert; + value.Db.ConsumeEntityEvent.OnInsert += ConsumeEntityEventOnInsert; value.Db.Entity.OnUpdate += EntityOnUpdate; value.Db.Entity.OnDelete += EntityOnDelete; value.Db.Food.OnInsert += FoodOnInsert; @@ -38,15 +40,19 @@ private DbConnection Conn private static Dictionary Entities { get; } = new(); private static Dictionary Players { get; } = new(); + private static HashSet PendingConsumeAnimations { get; } = new(); + public static IReadOnlyDictionary PlayerControllers => Players; public Instantiator(DbConnection conn) { + Entities.Clear(); + Players.Clear(); + PendingConsumeAnimations.Clear(); Conn = conn; } public override void _ExitTree() { - GD.Print("Instantiator Exit Tree"); Conn = null; } @@ -69,10 +75,28 @@ private void EntityOnDelete(EventContext context, Entity oldEntity) { if (Entities.Remove(oldEntity.EntityId, out var entityController)) { + if (PendingConsumeAnimations.Remove(oldEntity.EntityId)) + { + entityController.OnConsumed(); + return; + } + entityController.OnDelete(); } } + private void ConsumeEntityEventOnInsert(EventContext context, ConsumeEntityEvent evt) + { + if (!Entities.TryGetValue(evt.ConsumedEntityId, out var consumedEntity) || + !Entities.TryGetValue(evt.ConsumerEntityId, out var consumerEntity)) + { + return; + } + + PendingConsumeAnimations.Add(evt.ConsumedEntityId); + consumedEntity.StartDespawn(consumerEntity); + } + private void FoodOnInsert(EventContext context, Food insertedValue) { var entityController = SpawnFood(insertedValue); diff --git a/demo/Blackholio/client-godot/PlayerController.cs b/demo/Blackholio/client-godot/PlayerController.cs index 6dd2b4d8566..890de173c1c 100644 --- a/demo/Blackholio/client-godot/PlayerController.cs +++ b/demo/Blackholio/client-godot/PlayerController.cs @@ -16,8 +16,12 @@ public partial class PlayerController : Node private readonly List _ownedCircles = new(); private bool _lockInputTogglePressed; + private bool _splitPressed; + private bool _suicidePressed; + private bool _testInputEnabled; + private Vector2 _testInput; - public string Username => GameManager.Conn.Db.Player.PlayerId.Find(_playerId).Name; + public string Username => GameManager.Conn.Db.Player.PlayerId.Find(_playerId)?.Name ?? ""; public int NumberOfOwnedCircles => _ownedCircles.Count; public bool IsLocalPlayer => this == Local; @@ -54,7 +58,10 @@ public void OnCircleSpawned(CircleController circle) public void OnCircleDeleted(CircleController deletedCircle) { - _ownedCircles.Remove(deletedCircle); + if (_ownedCircles.Remove(deletedCircle) && IsLocalPlayer && _ownedCircles.Count == 0) + { + HudController.Instance?.ShowDeathScreen(true); + } } public int TotalMass() => _ownedCircles @@ -93,6 +100,21 @@ public bool TryGetCenterOfMass(out Vector2 centerOfMass) public override void _Process(double delta) { if (!IsLocalPlayer || NumberOfOwnedCircles == 0 || !GameManager.IsConnected()) return; + if (!_testInputEnabled && GetViewport().GuiGetFocusOwner() is LineEdit) return; + + var splitPressed = Input.IsPhysicalKeyPressed(Key.Space); + if (splitPressed && !_splitPressed) + { + GameManager.Conn.Reducers.PlayerSplit(); + } + _splitPressed = splitPressed; + + var suicidePressed = Input.IsPhysicalKeyPressed(Key.S); + if (suicidePressed && !_suicidePressed) + { + GameManager.Conn.Reducers.Suicide(); + } + _suicidePressed = suicidePressed; var lockTogglePressed = Input.IsPhysicalKeyPressed(Key.Q); if (lockTogglePressed && !_lockInputTogglePressed) @@ -116,8 +138,13 @@ public override void _Process(double delta) var mousePosition = _lockInputPosition ?? GetViewport().GetMousePosition(); var screenSize = GetViewport().GetVisibleRect().Size; var centerOfScreen = screenSize / 2.0f; - var direction = (mousePosition - centerOfScreen) / (screenSize.Y / 3.0f); + var direction = _testInputEnabled + ? _testInput + : (mousePosition - centerOfScreen) / (screenSize.Y / 3.0f); GameManager.Conn.Reducers.UpdatePlayerInput(direction); } -} \ No newline at end of file + + public void SetTestInput(Vector2 input) => _testInput = input; + public void EnableTestInput() => _testInputEnabled = true; +} diff --git a/demo/Blackholio/client-godot/StarfieldBackground.cs b/demo/Blackholio/client-godot/StarfieldBackground.cs new file mode 100644 index 00000000000..2541023c33f --- /dev/null +++ b/demo/Blackholio/client-godot/StarfieldBackground.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using Godot; + +public partial class StarfieldBackground : Node2D +{ + private readonly float _worldSize; + private readonly Color _backgroundColor; + private readonly List _stars = new(); + private float _time; + + public StarfieldBackground(float worldSize, Color backgroundColor) + { + _worldSize = worldSize; + _backgroundColor = backgroundColor; + Name = "Starfield"; + ZIndex = -1000; + GenerateStars(); + } + + public override void _Process(double delta) + { + _time += (float)delta; + QueueRedraw(); + } + + public override void _Draw() + { + DrawRect(new Rect2(Vector2.Zero, new Vector2(_worldSize, _worldSize)), _backgroundColor); + + DrawCircle(new Vector2(_worldSize * 0.22f, _worldSize * 0.68f), _worldSize * 0.18f, new Color(0.15f, 0.32f, 0.58f, 0.08f)); + DrawCircle(new Vector2(_worldSize * 0.76f, _worldSize * 0.24f), _worldSize * 0.22f, new Color(0.45f, 0.16f, 0.52f, 0.07f)); + DrawCircle(new Vector2(_worldSize * 0.54f, _worldSize * 0.55f), _worldSize * 0.26f, new Color(0.0f, 0.62f, 0.72f, 0.045f)); + + foreach (var star in _stars) + { + var pulse = 0.68f + 0.22f * Mathf.Sin(_time * star.TwinkleSpeed + star.Phase); + var color = WithAlpha(star.Color, star.Color.A * pulse); + DrawCircle(star.Position, star.Radius * (0.9f + pulse * 0.1f), color); + } + } + + private void GenerateStars() + { + var rng = new RandomNumberGenerator + { + Seed = 0xB1AC40E10 + }; + + var count = Mathf.RoundToInt(_worldSize * 0.55f); + for (var i = 0; i < count; i++) + { + var warmth = rng.RandfRange(0.0f, 1.0f); + _stars.Add(new Star + { + Position = new Vector2(rng.RandfRange(0, _worldSize), rng.RandfRange(0, _worldSize)), + Radius = rng.RandfRange(0.35f, 1.15f), + Phase = rng.RandfRange(0, Mathf.Tau), + TwinkleSpeed = rng.RandfRange(0.7f, 1.9f), + Color = new Color( + Mathf.Lerp(0.50f, 0.78f, warmth), + Mathf.Lerp(0.56f, 0.78f, warmth), + Mathf.Lerp(0.76f, 0.94f, warmth), + rng.RandfRange(0.16f, 0.42f) + ) + }); + } + } + + private static Color WithAlpha(Color color, float alpha) => new(color.R, color.G, color.B, alpha); + + private struct Star + { + public Vector2 Position; + public float Radius; + public float Phase; + public float TwinkleSpeed; + public Color Color; + } +} diff --git a/demo/Blackholio/client-godot/StarfieldBackground.cs.uid b/demo/Blackholio/client-godot/StarfieldBackground.cs.uid new file mode 100644 index 00000000000..5be96cb4981 --- /dev/null +++ b/demo/Blackholio/client-godot/StarfieldBackground.cs.uid @@ -0,0 +1 @@ +uid://c8t3gv7vf13wh diff --git a/demo/Blackholio/client-godot/blackholio.csproj b/demo/Blackholio/client-godot/blackholio.csproj index 832cd3baa10..d9586ac79f2 100644 --- a/demo/Blackholio/client-godot/blackholio.csproj +++ b/demo/Blackholio/client-godot/blackholio.csproj @@ -7,4 +7,7 @@ + + + diff --git a/demo/Blackholio/client-godot/module_bindings/Reducers/PlayerSplit.g.cs b/demo/Blackholio/client-godot/module_bindings/Reducers/PlayerSplit.g.cs new file mode 100644 index 00000000000..85b0502b6d6 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Reducers/PlayerSplit.g.cs @@ -0,0 +1,53 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteReducers : RemoteBase + { + public delegate void PlayerSplitHandler(ReducerEventContext ctx); + public event PlayerSplitHandler? OnPlayerSplit; + + public void PlayerSplit() + { + conn.InternalCallReducer(new Reducer.PlayerSplit()); + } + + public bool InvokePlayerSplit(ReducerEventContext ctx, Reducer.PlayerSplit args) + { + if (OnPlayerSplit == null) + { + if (InternalOnUnhandledReducerError != null) + { + switch (ctx.Event.Status) + { + case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break; + case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break; + } + } + return false; + } + OnPlayerSplit( + ctx + ); + return true; + } + } + + public abstract partial class Reducer + { + [SpacetimeDB.Type] + [DataContract] + public sealed partial class PlayerSplit : Reducer, IReducerArgs + { + string IReducerArgs.ReducerName => "player_split"; + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Reducers/PlayerSplit.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Reducers/PlayerSplit.g.cs.uid new file mode 100644 index 00000000000..1b56832d264 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Reducers/PlayerSplit.g.cs.uid @@ -0,0 +1 @@ +uid://dek0dhkjsoknv diff --git a/demo/Blackholio/client-godot/module_bindings/Reducers/Respawn.g.cs b/demo/Blackholio/client-godot/module_bindings/Reducers/Respawn.g.cs new file mode 100644 index 00000000000..44273b7c198 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Reducers/Respawn.g.cs @@ -0,0 +1,53 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteReducers : RemoteBase + { + public delegate void RespawnHandler(ReducerEventContext ctx); + public event RespawnHandler? OnRespawn; + + public void Respawn() + { + conn.InternalCallReducer(new Reducer.Respawn()); + } + + public bool InvokeRespawn(ReducerEventContext ctx, Reducer.Respawn args) + { + if (OnRespawn == null) + { + if (InternalOnUnhandledReducerError != null) + { + switch (ctx.Event.Status) + { + case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break; + case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break; + } + } + return false; + } + OnRespawn( + ctx + ); + return true; + } + } + + public abstract partial class Reducer + { + [SpacetimeDB.Type] + [DataContract] + public sealed partial class Respawn : Reducer, IReducerArgs + { + string IReducerArgs.ReducerName => "respawn"; + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Reducers/Respawn.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Reducers/Respawn.g.cs.uid new file mode 100644 index 00000000000..c62572302f5 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Reducers/Respawn.g.cs.uid @@ -0,0 +1 @@ +uid://csmdhwyqlohe2 diff --git a/demo/Blackholio/client-godot/module_bindings/Reducers/Suicide.g.cs b/demo/Blackholio/client-godot/module_bindings/Reducers/Suicide.g.cs new file mode 100644 index 00000000000..428d87e5d34 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Reducers/Suicide.g.cs @@ -0,0 +1,53 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteReducers : RemoteBase + { + public delegate void SuicideHandler(ReducerEventContext ctx); + public event SuicideHandler? OnSuicide; + + public void Suicide() + { + conn.InternalCallReducer(new Reducer.Suicide()); + } + + public bool InvokeSuicide(ReducerEventContext ctx, Reducer.Suicide args) + { + if (OnSuicide == null) + { + if (InternalOnUnhandledReducerError != null) + { + switch (ctx.Event.Status) + { + case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break; + case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break; + } + } + return false; + } + OnSuicide( + ctx + ); + return true; + } + } + + public abstract partial class Reducer + { + [SpacetimeDB.Type] + [DataContract] + public sealed partial class Suicide : Reducer, IReducerArgs + { + string IReducerArgs.ReducerName => "suicide"; + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Reducers/Suicide.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Reducers/Suicide.g.cs.uid new file mode 100644 index 00000000000..e630560a7bd --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Reducers/Suicide.g.cs.uid @@ -0,0 +1 @@ +uid://chf5c54fymt0 diff --git a/demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs b/demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs index 8a49f001f5e..44743e2dfab 100644 --- a/demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs +++ b/demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs @@ -29,6 +29,7 @@ public RemoteTables(DbConnection conn) { AddTable(Circle = new(conn)); AddTable(Config = new(conn)); + AddTable(ConsumeEntityEvent = new(conn)); AddTable(Entity = new(conn)); AddTable(Food = new(conn)); AddTable(Player = new(conn)); @@ -530,6 +531,7 @@ public sealed class QueryBuilder { new QueryBuilder().From.Circle().ToSql(), new QueryBuilder().From.Config().ToSql(), + new QueryBuilder().From.ConsumeEntityEvent().ToSql(), new QueryBuilder().From.Entity().ToSql(), new QueryBuilder().From.Food().ToSql(), new QueryBuilder().From.Player().ToSql(), @@ -541,6 +543,7 @@ public sealed class From { public global::SpacetimeDB.Table Circle() => new("circle", new CircleCols("circle"), new CircleIxCols("circle")); public global::SpacetimeDB.Table Config() => new("config", new ConfigCols("config"), new ConfigIxCols("config")); + public global::SpacetimeDB.Table ConsumeEntityEvent() => new("consume_entity_event", new ConsumeEntityEventCols("consume_entity_event"), new ConsumeEntityEventIxCols("consume_entity_event")); public global::SpacetimeDB.Table Entity() => new("entity", new EntityCols("entity"), new EntityIxCols("entity")); public global::SpacetimeDB.Table Food() => new("food", new FoodCols("food"), new FoodIxCols("food")); public global::SpacetimeDB.Table Player() => new("player", new PlayerCols("player"), new PlayerIxCols("player")); @@ -626,6 +629,9 @@ protected override bool Dispatch(IReducerEventContext context, Reducer reducer) return reducer switch { Reducer.EnterGame args => Reducers.InvokeEnterGame(eventContext, args), + Reducer.PlayerSplit args => Reducers.InvokePlayerSplit(eventContext, args), + Reducer.Respawn args => Reducers.InvokeRespawn(eventContext, args), + Reducer.Suicide args => Reducers.InvokeSuicide(eventContext, args), Reducer.UpdatePlayerInput args => Reducers.InvokeUpdatePlayerInput(eventContext, args), _ => throw new ArgumentOutOfRangeException("Reducer", $"Unknown reducer {reducer}") }; diff --git a/demo/Blackholio/client-godot/module_bindings/Tables/ConsumeEntityEvent.g.cs b/demo/Blackholio/client-godot/module_bindings/Tables/ConsumeEntityEvent.g.cs new file mode 100644 index 00000000000..3bd52a92934 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/ConsumeEntityEvent.g.cs @@ -0,0 +1,47 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using SpacetimeDB.BSATN; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteTables + { + public sealed class ConsumeEntityEventHandle : RemoteEventTableHandle + { + protected override string RemoteTableName => "consume_entity_event"; + + internal ConsumeEntityEventHandle(DbConnection conn) : base(conn) + { + } + } + + public readonly ConsumeEntityEventHandle ConsumeEntityEvent; + } + + public sealed class ConsumeEntityEventCols + { + public global::SpacetimeDB.Col ConsumedEntityId { get; } + public global::SpacetimeDB.Col ConsumerEntityId { get; } + + public ConsumeEntityEventCols(string tableName) + { + ConsumedEntityId = new global::SpacetimeDB.Col(tableName, "consumed_entity_id"); + ConsumerEntityId = new global::SpacetimeDB.Col(tableName, "consumer_entity_id"); + } + } + + public sealed class ConsumeEntityEventIxCols + { + + public ConsumeEntityEventIxCols(string tableName) + { + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Tables/ConsumeEntityEvent.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Tables/ConsumeEntityEvent.g.cs.uid new file mode 100644 index 00000000000..492bd7d79f8 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/ConsumeEntityEvent.g.cs.uid @@ -0,0 +1 @@ +uid://5ic37n1ymqht diff --git a/demo/Blackholio/client-godot/module_bindings/Types/CircleDecayTimer.g.cs b/demo/Blackholio/client-godot/module_bindings/Types/CircleDecayTimer.g.cs new file mode 100644 index 00000000000..922691cdbee --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/CircleDecayTimer.g.cs @@ -0,0 +1,35 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + [SpacetimeDB.Type] + [DataContract] + public sealed partial class CircleDecayTimer + { + [DataMember(Name = "scheduled_id")] + public ulong ScheduledId; + [DataMember(Name = "scheduled_at")] + public SpacetimeDB.ScheduleAt ScheduledAt; + + public CircleDecayTimer( + ulong ScheduledId, + SpacetimeDB.ScheduleAt ScheduledAt + ) + { + this.ScheduledId = ScheduledId; + this.ScheduledAt = ScheduledAt; + } + + public CircleDecayTimer() + { + this.ScheduledAt = null!; + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Types/CircleDecayTimer.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Types/CircleDecayTimer.g.cs.uid new file mode 100644 index 00000000000..43be02aca1a --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/CircleDecayTimer.g.cs.uid @@ -0,0 +1 @@ +uid://vsnqo4opsvx diff --git a/demo/Blackholio/client-godot/module_bindings/Types/CircleRecombineTimer.g.cs b/demo/Blackholio/client-godot/module_bindings/Types/CircleRecombineTimer.g.cs new file mode 100644 index 00000000000..92676f91569 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/CircleRecombineTimer.g.cs @@ -0,0 +1,39 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + [SpacetimeDB.Type] + [DataContract] + public sealed partial class CircleRecombineTimer + { + [DataMember(Name = "scheduled_id")] + public ulong ScheduledId; + [DataMember(Name = "scheduled_at")] + public SpacetimeDB.ScheduleAt ScheduledAt; + [DataMember(Name = "player_id")] + public int PlayerId; + + public CircleRecombineTimer( + ulong ScheduledId, + SpacetimeDB.ScheduleAt ScheduledAt, + int PlayerId + ) + { + this.ScheduledId = ScheduledId; + this.ScheduledAt = ScheduledAt; + this.PlayerId = PlayerId; + } + + public CircleRecombineTimer() + { + this.ScheduledAt = null!; + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Types/CircleRecombineTimer.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Types/CircleRecombineTimer.g.cs.uid new file mode 100644 index 00000000000..c27b26ee658 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/CircleRecombineTimer.g.cs.uid @@ -0,0 +1 @@ +uid://cotoq33ukm1ux diff --git a/demo/Blackholio/client-godot/module_bindings/Types/ConsumeEntityEvent.g.cs b/demo/Blackholio/client-godot/module_bindings/Types/ConsumeEntityEvent.g.cs new file mode 100644 index 00000000000..66a958137b3 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/ConsumeEntityEvent.g.cs @@ -0,0 +1,34 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + [SpacetimeDB.Type] + [DataContract] + public sealed partial class ConsumeEntityEvent + { + [DataMember(Name = "consumed_entity_id")] + public int ConsumedEntityId; + [DataMember(Name = "consumer_entity_id")] + public int ConsumerEntityId; + + public ConsumeEntityEvent( + int ConsumedEntityId, + int ConsumerEntityId + ) + { + this.ConsumedEntityId = ConsumedEntityId; + this.ConsumerEntityId = ConsumerEntityId; + } + + public ConsumeEntityEvent() + { + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Types/ConsumeEntityEvent.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Types/ConsumeEntityEvent.g.cs.uid new file mode 100644 index 00000000000..38a98a0b07c --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/ConsumeEntityEvent.g.cs.uid @@ -0,0 +1 @@ +uid://xajrcx3rcisc diff --git a/demo/Blackholio/client-godot/module_bindings/Types/ConsumeEntityTimer.g.cs b/demo/Blackholio/client-godot/module_bindings/Types/ConsumeEntityTimer.g.cs new file mode 100644 index 00000000000..fc715f9fa7e --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/ConsumeEntityTimer.g.cs @@ -0,0 +1,43 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + [SpacetimeDB.Type] + [DataContract] + public sealed partial class ConsumeEntityTimer + { + [DataMember(Name = "scheduled_id")] + public ulong ScheduledId; + [DataMember(Name = "scheduled_at")] + public SpacetimeDB.ScheduleAt ScheduledAt; + [DataMember(Name = "consumed_entity_id")] + public int ConsumedEntityId; + [DataMember(Name = "consumer_entity_id")] + public int ConsumerEntityId; + + public ConsumeEntityTimer( + ulong ScheduledId, + SpacetimeDB.ScheduleAt ScheduledAt, + int ConsumedEntityId, + int ConsumerEntityId + ) + { + this.ScheduledId = ScheduledId; + this.ScheduledAt = ScheduledAt; + this.ConsumedEntityId = ConsumedEntityId; + this.ConsumerEntityId = ConsumerEntityId; + } + + public ConsumeEntityTimer() + { + this.ScheduledAt = null!; + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Types/ConsumeEntityTimer.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Types/ConsumeEntityTimer.g.cs.uid new file mode 100644 index 00000000000..044f917b041 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/ConsumeEntityTimer.g.cs.uid @@ -0,0 +1 @@ +uid://dc825ue1mwoy5 diff --git a/demo/Blackholio/client-godot/tests/GodotPlayModeTests.cs b/demo/Blackholio/client-godot/tests/GodotPlayModeTests.cs new file mode 100644 index 00000000000..e18df0f5b87 --- /dev/null +++ b/demo/Blackholio/client-godot/tests/GodotPlayModeTests.cs @@ -0,0 +1,295 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Godot; +using SpacetimeDB; +using SpacetimeDB.Types; + +public partial class GodotPlayModeTests : Node +{ + private const string ServerUrl = "http://127.0.0.1:3000"; + private const string DatabaseName = "blackholio"; + private const string DefaultPlayerName = "3Blave"; + + public override async void _Ready() + { + // This should not be needed after improving the SDK + await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); + + var failures = 0; + failures += await RunTest(nameof(SimpleConnectionTest), SimpleConnectionTest); + failures += await RunTest(nameof(CreatePlayerAndTestDecay), CreatePlayerAndTestDecay); + failures += await RunTest(nameof(OneOffQueryTest), OneOffQueryTest); + failures += await RunTest(nameof(ReconnectionViaReloadingScene), ReconnectionViaReloadingScene); + + GetTree().Quit(failures == 0 ? 0 : 1); + } + + private async Task RunTest(string name, Func test) + { + GD.Print($"[GodotTests] START {name}"); + try + { + await test(); + GD.Print($"[GodotTests] PASS {name}"); + return 0; + } + catch (Exception ex) + { + GD.PrintErr($"[GodotTests] FAIL {name}: {ex}"); + return 1; + } + finally + { + await UnloadMainScene(); + } + } + + private async Task SimpleConnectionTest() + { + var connected = false; + Exception connectError = null; + var conn = DbConnection.Builder() + .OnConnect((_, _, _) => connected = true) + .OnConnectError(ex => connectError = ex) + .WithUri(ServerUrl) + .WithDatabaseName(DatabaseName) + .Build(); + + STDBUpdateManager.Add(conn); + try + { + await WaitUntil(() => connected || connectError != null, "Connection did not complete."); + Assert(connectError == null, $"Connection failed: {connectError}"); + Assert(connected, "Connection callback did not run."); + } + finally + { + STDBUpdateManager.Remove(conn, true); + } + } + + private async Task CreatePlayerAndTestDecay() + { + ClearSavedAuthToken(); + await LoadMainScene(); + await WaitForLocalPlayer(); + + var player = FindLocalPlayer(); + var circle = FindPlayerCircle(player.PlayerId); + Assert(circle != null, "Local player circle was not created."); + + var foodEaten = 0; + GameManager.Conn.Db.Food.OnDelete += (_, _) => foodEaten++; + + PlayerController.Local.EnableTestInput(); + await WaitUntil(() => + { + SteerTowardNearestFood(circle, foodEaten); + return foodEaten >= 50; + }, "Player did not eat enough food.", timeoutSeconds: 60); + + PlayerController.Local.SetTestInput(Vector2.Zero); + var massStart = GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId).Mass; + await WaitSeconds(10); + var massEnd = GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId).Mass; + Assert(massEnd < massStart, $"Mass should decay. start={massStart}, end={massEnd}"); + } + + private async Task OneOffQueryTest() + { + ClearSavedAuthToken(); + await LoadMainScene(); + await WaitForLocalPlayer(); + + var task = GameManager.Conn.Db.Player.RemoteQuery($"WHERE identity=0x{GameManager.LocalIdentity}"); + Task.Run(() => task.RunSynchronously()); + await WaitUntil(() => task.IsCompleted, "One-off query did not complete."); + + var players = task.Result; + Assert(players.Length == 1, $"Expected one player, found {players.Length}."); + Assert(players[0].Name == DefaultPlayerName, $"Expected username {DefaultPlayerName}, found {players[0].Name}."); + } + + private async Task ReconnectionViaReloadingScene() + { + ClearSavedAuthToken(); + await LoadMainScene(); + await WaitForLocalPlayer(); + + var player = FindLocalPlayer(); + var circle = FindPlayerCircle(player.PlayerId); + Assert(circle != null, "Local player circle was not created before reconnect."); + + await UnloadMainScene(); + + await LoadMainScene(clearAuthToken: false); + await WaitForLocalPlayer(); + + var newPlayer = FindLocalPlayer(); + var newCircle = FindPlayerCircle(newPlayer.PlayerId); + Assert(newCircle != null, "Local player circle was not restored after reconnect."); + Assert(player.PlayerId == newPlayer.PlayerId, "Player ids should match after reconnect."); + Assert(circle.EntityId == newCircle.EntityId, "Circle entity ids should match after reconnect."); + } + + private async Task LoadMainScene(bool clearAuthToken = true) + { + if (clearAuthToken) + { + ClearSavedAuthToken(); + } + + var connected = false; + var subscribed = false; + void OnConnected() => connected = true; + void OnSubscriptionApplied() => subscribed = true; + + GameManager.OnConnected += OnConnected; + GameManager.OnSubscriptionApplied += OnSubscriptionApplied; + + var scene = GD.Load("res://main.tscn"); + AddChild(scene.Instantiate()); + + try + { + await WaitUntil(() => connected, "GameManager did not connect."); + await WaitUntil(() => subscribed, "GameManager subscription did not apply."); + SubmitUsernameIfNeeded(); + } + finally + { + GameManager.OnConnected -= OnConnected; + GameManager.OnSubscriptionApplied -= OnSubscriptionApplied; + } + } + + private async Task UnloadMainScene() + { + var main = GetNodeOrNull("Main"); + if (main != null) + { + main.QueueFree(); + await NextFrame(); + } + + if (GameManager.Conn != null) + { + await WaitUntil(() => GameManager.Conn == null || !GameManager.IsConnected(), "GameManager did not disconnect.", timeoutSeconds: 5); + } + } + + private async Task WaitForLocalPlayer() + { + await WaitUntil(() => + { + if (GameManager.Conn == null || GameManager.LocalIdentity == default) + { + return false; + } + + var player = GameManager.Conn.Db.Player.Identity.Find(GameManager.LocalIdentity); + return player != null + && !string.IsNullOrEmpty(player.Name) + && FindPlayerCircle(player.PlayerId) != null + && PlayerController.Local != null; + }, "Local player was not ready."); + } + + private static void SubmitUsernameIfNeeded() + { + var player = GameManager.Conn?.Db.Player.Identity.Find(GameManager.LocalIdentity); + if (player == null || string.IsNullOrEmpty(player.Name)) + { + HudController.Instance?.SubmitUsernameForTests(DefaultPlayerName); + } + } + + private static Player FindLocalPlayer() => GameManager.Conn.Db.Player.Identity.Find(GameManager.LocalIdentity); + + private static Circle FindPlayerCircle(int playerId) => + GameManager.Conn.Db.Circle.PlayerId.Filter(playerId).FirstOrDefault(); + + private static void SteerTowardNearestFood(Circle circle, int foodEaten) + { + var ourEntity = GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId); + Assert(ourEntity != null, "Local circle entity was not found."); + + var toChosenFood = new Vector2(1000, 0); + var chosenFoodId = 0; + foreach (var food in GameManager.Conn.Db.Food.Iter()) + { + var foodEntity = GameManager.Conn.Db.Entity.EntityId.Find(food.EntityId); + if (foodEntity == null) + { + continue; + } + + var toThisFood = (Vector2)foodEntity.Position - (Vector2)ourEntity.Position; + if (toThisFood.LengthSquared() == 0.0f) + { + continue; + } + + if (toChosenFood.LengthSquared() > toThisFood.LengthSquared()) + { + chosenFoodId = food.EntityId; + toChosenFood = toThisFood; + } + } + + if (chosenFoodId == 0 || GameManager.Conn.Db.Entity.EntityId.Find(chosenFoodId) == null) + { + PlayerController.Local.SetTestInput(Vector2.Zero); + return; + } + + var foodTarget = GameManager.Conn.Db.Entity.EntityId.Find(chosenFoodId); + var currentEntity = GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId); + Assert(foodTarget != null, "Chosen food entity was not found."); + Assert(currentEntity != null, "Local circle entity was not found."); + + var direction = (Vector2)foodTarget.Position - (Vector2)currentEntity.Position; + if (foodEaten < 10) + { + direction = direction.Normalized() * 0.5f; + } + + PlayerController.Local.SetTestInput(direction); + } + + private async Task WaitUntil(Func predicate, string message, double timeoutSeconds = 30) + { + var deadline = Time.GetTicksMsec() + (ulong)(timeoutSeconds * 1000); + while (!predicate()) + { + if (Time.GetTicksMsec() >= deadline) + { + throw new TimeoutException(message); + } + + await NextFrame(); + } + } + + private async Task WaitSeconds(double seconds) + { + var deadline = Time.GetTicksMsec() + (ulong)(seconds * 1000); + while (Time.GetTicksMsec() < deadline) + { + await NextFrame(); + } + } + + private async Task NextFrame() => await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); + + private static void ClearSavedAuthToken() => AuthToken.SaveToken(""); + + private static void Assert(bool condition, string message) + { + if (!condition) + { + throw new Exception(message); + } + } +} diff --git a/demo/Blackholio/client-godot/tests/GodotPlayModeTests.cs.uid b/demo/Blackholio/client-godot/tests/GodotPlayModeTests.cs.uid new file mode 100644 index 00000000000..68e79505434 --- /dev/null +++ b/demo/Blackholio/client-godot/tests/GodotPlayModeTests.cs.uid @@ -0,0 +1 @@ +uid://c5q42be5pif8h diff --git a/demo/Blackholio/client-godot/tests/GodotPlayModeTests.tscn b/demo/Blackholio/client-godot/tests/GodotPlayModeTests.tscn new file mode 100644 index 00000000000..1bee72e4673 --- /dev/null +++ b/demo/Blackholio/client-godot/tests/GodotPlayModeTests.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://dqyv2qhg7qrdg"] + +[ext_resource type="Script" uid="uid://c5q42be5pif8h" path="res://tests/GodotPlayModeTests.cs" id="1_tests"] + +[node name="GodotPlayModeTests" type="Node" unique_id=616781920] +script = ExtResource("1_tests") diff --git a/demo/Blackholio/server-csharp/generate.bat b/demo/Blackholio/server-csharp/generate.bat index ee75551ed83..d749656b23a 100644 --- a/demo/Blackholio/server-csharp/generate.bat +++ b/demo/Blackholio/server-csharp/generate.bat @@ -1,2 +1,3 @@ spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen -y --lang cs --module-path ./ +spacetime generate --out-dir ../client-godot/module_bindings -y --lang cs --module-path ./ spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --module-path ./ --module-name client_unreal diff --git a/demo/Blackholio/server-csharp/generate.sh b/demo/Blackholio/server-csharp/generate.sh index 36ed983fc51..c1d043670a1 100644 --- a/demo/Blackholio/server-csharp/generate.sh +++ b/demo/Blackholio/server-csharp/generate.sh @@ -3,4 +3,5 @@ set -euo pipefail spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen --lang cs --module-path ./ $@ +spacetime generate --out-dir ../client-godot/module_bindings --lang cs --module-path ./ $@ spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --module-path ./ --module-name client_unreal diff --git a/demo/Blackholio/server-rust/generate.bat b/demo/Blackholio/server-rust/generate.bat index ee75551ed83..d749656b23a 100644 --- a/demo/Blackholio/server-rust/generate.bat +++ b/demo/Blackholio/server-rust/generate.bat @@ -1,2 +1,3 @@ spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen -y --lang cs --module-path ./ +spacetime generate --out-dir ../client-godot/module_bindings -y --lang cs --module-path ./ spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --module-path ./ --module-name client_unreal diff --git a/demo/Blackholio/server-rust/generate.sh b/demo/Blackholio/server-rust/generate.sh index 36ed983fc51..c1d043670a1 100755 --- a/demo/Blackholio/server-rust/generate.sh +++ b/demo/Blackholio/server-rust/generate.sh @@ -3,4 +3,5 @@ set -euo pipefail spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen --lang cs --module-path ./ $@ +spacetime generate --out-dir ../client-godot/module_bindings --lang cs --module-path ./ $@ spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --module-path ./ --module-name client_unreal diff --git a/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00500-part-4.md b/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00500-part-4.md index c7873407f34..bfc58fc0ded 100644 --- a/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00500-part-4.md +++ b/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00500-part-4.md @@ -928,8 +928,9 @@ There's still plenty more we can do to build this into a proper game though. For - Nice animations - Nice shaders - Space theme! +- Object Pooling (for FoodController, PlayerController and CircleController) -Fortunately, we've done that for you! If you'd like to check out the completed tutorial game, with these additional features, you can download it on GitHub: +Fortunately, we've done that for you! If you'd like to check out the completed tutorial game, with most of these additional features, you can download it on GitHub: [https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio) diff --git a/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00500-part-4.md b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00500-part-4.md index 5eb10c86987..71eff102a9c 100644 --- a/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00500-part-4.md +++ b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00500-part-4.md @@ -896,4 +896,8 @@ There's still plenty more we can do to build this into a proper game though. For - Space theme! - Object Pooling (for FoodController, PlayerController and CircleController) +Fortunately, we've done that for you! If you'd like to check out the completed tutorial game, with most of these additional features, you can download it on GitHub: + +[https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio) + If you have any suggestions or comments on the tutorial, either [open an issue](https://github.com/clockworklabs/SpacetimeDB/issues/new), or join our Discord ([https://discord.gg/SpacetimeDB](https://discord.gg/SpacetimeDB)) and chat with us! diff --git a/docs/static/images/godot/part-1-hero-image.png b/docs/static/images/godot/part-1-hero-image.png index b37d9690bfb..952d89468a0 100644 Binary files a/docs/static/images/godot/part-1-hero-image.png and b/docs/static/images/godot/part-1-hero-image.png differ diff --git a/sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj b/sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj index 61e3cda1f0e..cfc758ba0ac 100644 --- a/sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj +++ b/sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj @@ -28,12 +28,18 @@ bin~/$(Configuration)/ obj~/godot/ obj~/godot/$(Configuration)/$(TargetFramework)/ + false - + + + + + + diff --git a/sdks/csharp/src/STDBUpdateManager.cs b/sdks/csharp/src/STDBUpdateManager.cs index c2662b5b310..2c9e0e5c9b6 100644 --- a/sdks/csharp/src/STDBUpdateManager.cs +++ b/sdks/csharp/src/STDBUpdateManager.cs @@ -8,12 +8,12 @@ public partial class STDBUpdateManager : Node { private const string SingletonNodeName = nameof(STDBUpdateManager); - private static STDBUpdateManager _instance; - private static STDBUpdateManager Instance => EnsureInstance(); + private static STDBUpdateManager? _instance; + private static STDBUpdateManager? Instance => EnsureInstance(); private List Connections { get; } = new(); - private static STDBUpdateManager EnsureInstance() + private static STDBUpdateManager? EnsureInstance() { if (IsInstanceValid(_instance)) { @@ -44,13 +44,12 @@ private static STDBUpdateManager EnsureInstance() { Name = SingletonNodeName, }; - root.AddChild(_instance, false, InternalMode.Front); + root.CallDeferred(Node.MethodName.AddChild, _instance, false, (int)InternalMode.Front); return _instance; } public static bool Add(IDbConnection conn) { - if (conn == null) return false; var connections = Instance?.Connections; if (connections == null || connections.Contains(conn)) return false; connections.Add(conn); @@ -59,7 +58,6 @@ public static bool Add(IDbConnection conn) public static bool Remove(IDbConnection conn, bool disconnect = false) { - if (conn == null) return false; var connections = Instance?.Connections; if (connections != null && connections.Remove(conn)) { @@ -102,7 +100,7 @@ public override void _Process(double delta) { foreach (var conn in Connections) { - conn?.FrameTick(); + conn.FrameTick(); } } }