diff --git a/demo/Blackholio/client-godot/.editorconfig b/demo/Blackholio/client-godot/.editorconfig new file mode 100644 index 00000000000..f28239ba528 --- /dev/null +++ b/demo/Blackholio/client-godot/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/demo/Blackholio/client-godot/.gitattributes b/demo/Blackholio/client-godot/.gitattributes new file mode 100644 index 00000000000..8ad74f78d9c --- /dev/null +++ b/demo/Blackholio/client-godot/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/demo/Blackholio/client-godot/.gitignore b/demo/Blackholio/client-godot/.gitignore new file mode 100644 index 00000000000..0af181cfb54 --- /dev/null +++ b/demo/Blackholio/client-godot/.gitignore @@ -0,0 +1,3 @@ +# Godot 4+ specific ignores +.godot/ +/android/ diff --git a/demo/Blackholio/client-godot/CameraController.cs b/demo/Blackholio/client-godot/CameraController.cs new file mode 100644 index 00000000000..cb11b025661 --- /dev/null +++ b/demo/Blackholio/client-godot/CameraController.cs @@ -0,0 +1,49 @@ +using Godot; + +public partial class CameraController : Camera2D +{ + [Export] + public float BaseVisibleRadius { get; set; } = 50.0f; + + [Export] + public float FollowLerpSpeed { get; set; } = 8.0f; + + [Export] + public float ZoomLerpSpeed { get; set; } = 2.0f; + + private float WorldSize { get; } + + public CameraController(float worldSize) + { + WorldSize = worldSize; + } + + public override void _Process(double delta) + { + Vector2 targetPosition; + if (GameManager.IsConnected() && PlayerController.Local != null && PlayerController.Local.TryGetCenterOfMass(out var centerOfMass)) + { + targetPosition = centerOfMass; + } + else + { + var hWorldSize = WorldSize * 0.5f; + targetPosition = new Vector2(hWorldSize, hWorldSize); + } + + GlobalPosition = GlobalPosition.Lerp(targetPosition, (float)delta * FollowLerpSpeed); + + if (PlayerController.Local == null) + { + return; + } + + var targetCameraSize = CalculateCameraSize(PlayerController.Local); + var desiredZoom = Vector2.One * (BaseVisibleRadius / Mathf.Max(targetCameraSize, 1.0f)); + Zoom = Zoom.Lerp(desiredZoom, (float)delta * ZoomLerpSpeed); + } + + private static float CalculateCameraSize(PlayerController player) => 10.0f + + Mathf.Min(10.0f, player.TotalMass() / 5.0f) + + Mathf.Min(player.NumberOfOwnedCircles - 1, 1) * 30.0f; +} \ No newline at end of file diff --git a/demo/Blackholio/client-godot/CameraController.cs.uid b/demo/Blackholio/client-godot/CameraController.cs.uid new file mode 100644 index 00000000000..0c921f2962e --- /dev/null +++ b/demo/Blackholio/client-godot/CameraController.cs.uid @@ -0,0 +1 @@ +uid://c5uuweuf2vu0e diff --git a/demo/Blackholio/client-godot/Circle2D.cs b/demo/Blackholio/client-godot/Circle2D.cs new file mode 100644 index 00000000000..c414b7b6843 --- /dev/null +++ b/demo/Blackholio/client-godot/Circle2D.cs @@ -0,0 +1,34 @@ +using Godot; + +public partial class Circle2D : Node2D +{ + private float _radius = 10.0f; + [Export] + public float Radius + { + get => _radius; + set + { + if (Mathf.IsEqualApprox(_radius, value)) return; + + _radius = value; + QueueRedraw(); + } + } + + private Color _color = Colors.Brown; + [Export] + public Color Color + { + get => _color; + set + { + if (_color == value) return; + + _color = value; + QueueRedraw(); + } + } + + public override void _Draw() => DrawCircle(Vector2.Zero, Radius, Color); +} diff --git a/demo/Blackholio/client-godot/Circle2D.cs.uid b/demo/Blackholio/client-godot/Circle2D.cs.uid new file mode 100644 index 00000000000..5119a960d52 --- /dev/null +++ b/demo/Blackholio/client-godot/Circle2D.cs.uid @@ -0,0 +1 @@ +uid://7lmrsq2i7mi1 diff --git a/demo/Blackholio/client-godot/CircleController.cs b/demo/Blackholio/client-godot/CircleController.cs new file mode 100644 index 00000000000..73504709042 --- /dev/null +++ b/demo/Blackholio/client-godot/CircleController.cs @@ -0,0 +1,115 @@ +using Godot; +using SpacetimeDB.Types; + +public partial class CircleController : EntityController +{ + private static readonly Color[] ColorPalette = + [ + //Yellow + new(175 / 255.0f, 159 / 255.0f, 49 / 255.0f), + new(175 / 255.0f, 116 / 255.0f, 49 / 255.0f), + //Purple + new(112 / 255.0f, 47 / 255.0f, 252 / 255.0f), + new(51 / 255.0f, 91 / 255.0f, 252 / 255.0f), + //Red + new(176 / 255.0f, 54 / 255.0f, 54 / 255.0f), + new(176 / 255.0f, 109 / 255.0f, 54 / 255.0f), + new(141 / 255.0f, 43 / 255.0f, 99 / 255.0f), + //Blue + new(2 / 255.0f, 188 / 255.0f, 250 / 255.0f), + new(7 / 255.0f, 50 / 255.0f, 251 / 255.0f), + new(2 / 255.0f, 28 / 255.0f, 146 / 255.0f) + ]; + + private static CanvasLayer _labelLayer; + private static CanvasLayer LabelLayer + { + get + { + if (_labelLayer == null) + { + + if (Engine.GetMainLoop() is not SceneTree sceneTree) return null; + var root = sceneTree.Root; + if (root == null) return null; + _labelLayer = new CanvasLayer { Name = "CircleLabelLayer" }; + root.AddChild(_labelLayer); + } + return _labelLayer; + } + } + private static Control _labelRoot; + private static Control LabelRoot + { + get + { + if (_labelRoot == null) + { + _labelRoot = new Control + { + Name = "CircleLabelRoot", + MouseFilter = Control.MouseFilterEnum.Ignore + }; + _labelRoot.SetAnchorsPreset(Control.LayoutPreset.FullRect); + + LabelLayer.AddChild(_labelRoot); + } + return _labelRoot; + } + } + + private Label _label; + private Label Label + { + get + { + if (_label == null) + { + _label = new Label + { + Name = $"{Name}_Label", + TopLevel = false, + MouseFilter = Control.MouseFilterEnum.Ignore + }; + LabelRoot.AddChild(_label); + } + return _label; + } + } + + private PlayerController OwnerPlayer { get; set; } + + public CircleController(Circle circle, PlayerController ownerPlayer) : base(circle.EntityId, ColorPalette[circle.PlayerId % ColorPalette.Length]) + { + OwnerPlayer = ownerPlayer; + Label.Text = ownerPlayer.Username; + + ownerPlayer.OnCircleSpawned(this); + } + + public override void _Process(double delta) + { + base._Process(delta); + UpdateScreenLabelPosition(); + } + + public override void OnDelete() + { + base.OnDelete(); + + if (IsInstanceValid(Label)) + { + Label.QueueFree(); + } + + OwnerPlayer?.OnCircleDeleted(this); + } + + private void UpdateScreenLabelPosition() + { + Label.Size = Label.GetCombinedMinimumSize(); + var screenPosition = GetGlobalTransformWithCanvas().Origin; + var offset = new Vector2(0.0f, Radius + 8.0f); + Label.Position = screenPosition + offset - (Label.Size / 2.0f); + } +} diff --git a/demo/Blackholio/client-godot/CircleController.cs.uid b/demo/Blackholio/client-godot/CircleController.cs.uid new file mode 100644 index 00000000000..86a1c5d4c36 --- /dev/null +++ b/demo/Blackholio/client-godot/CircleController.cs.uid @@ -0,0 +1 @@ +uid://c328rnk2rjynl diff --git a/demo/Blackholio/client-godot/EntityController.cs b/demo/Blackholio/client-godot/EntityController.cs new file mode 100644 index 00000000000..207354f0489 --- /dev/null +++ b/demo/Blackholio/client-godot/EntityController.cs @@ -0,0 +1,48 @@ +using Godot; +using SpacetimeDB.Types; + +public abstract partial class EntityController : Circle2D +{ + private const float LerpDurationSec = 0.1f; + + public int EntityId { get; private set; } + + private float LerpTime { get; set; } + private Vector2 LerpStartPosition { get; set; } + private Vector2 TargetPosition { get; set; } + private float TargetRadius { get; set; } + + protected EntityController(int entityId, Color color) + { + EntityId = entityId; + Color = color; + + var entity = GameManager.Conn.Db.Entity.EntityId.Find(entityId); + var position = (Vector2)entity.Position; + LerpStartPosition = position; + TargetPosition = position; + GlobalPosition = position; + Radius = 0; + TargetRadius = MassToRadius(entity.Mass); + } + + public void OnEntityUpdated(Entity newRow) + { + LerpTime = 0.0f; + LerpStartPosition = GlobalPosition; + TargetPosition = (Vector2)newRow.Position; + TargetRadius = MassToRadius(newRow.Mass); + } + + public virtual void OnDelete() => QueueFree(); + + public override void _Process(double delta) + { + var frameDelta = (float)delta; + LerpTime = Mathf.Min(LerpTime + frameDelta, LerpDurationSec); + GlobalPosition = LerpStartPosition.Lerp(TargetPosition, LerpTime / LerpDurationSec); + Radius = Mathf.Lerp(Radius, TargetRadius, frameDelta * 8.0f); + } + + private static float MassToRadius(int mass) => Mathf.Sqrt(mass); +} diff --git a/demo/Blackholio/client-godot/EntityController.cs.uid b/demo/Blackholio/client-godot/EntityController.cs.uid new file mode 100644 index 00000000000..d50155b0921 --- /dev/null +++ b/demo/Blackholio/client-godot/EntityController.cs.uid @@ -0,0 +1 @@ +uid://bhtg3omtsxp7d diff --git a/demo/Blackholio/client-godot/Extensions.cs b/demo/Blackholio/client-godot/Extensions.cs new file mode 100644 index 00000000000..fe239418aba --- /dev/null +++ b/demo/Blackholio/client-godot/Extensions.cs @@ -0,0 +1,10 @@ +using Godot; + +namespace SpacetimeDB.Types +{ + public partial class DbVector2 + { + public static implicit operator Vector2(DbVector2 vec) => new(vec.X, vec.Y); + public static implicit operator DbVector2(Vector2 vec) => new(vec.X, vec.Y); + } +} diff --git a/demo/Blackholio/client-godot/Extensions.cs.uid b/demo/Blackholio/client-godot/Extensions.cs.uid new file mode 100644 index 00000000000..6052bfbd55c --- /dev/null +++ b/demo/Blackholio/client-godot/Extensions.cs.uid @@ -0,0 +1 @@ +uid://cxx8tt4m0yxbr diff --git a/demo/Blackholio/client-godot/FoodController.cs b/demo/Blackholio/client-godot/FoodController.cs new file mode 100644 index 00000000000..fc46ba0dbca --- /dev/null +++ b/demo/Blackholio/client-godot/FoodController.cs @@ -0,0 +1,17 @@ +using Godot; +using SpacetimeDB.Types; + +public partial class FoodController : EntityController +{ + private static readonly Color[] ColorPalette = + [ + new(119 / 255.0f, 252 / 255.0f, 173 / 255.0f), + new(76 / 255.0f, 250 / 255.0f, 146 / 255.0f), + new(35 / 255.0f, 246 / 255.0f, 120 / 255.0f), + new(119 / 255.0f, 251 / 255.0f, 201 / 255.0f), + new(76 / 255.0f, 249 / 255.0f, 184 / 255.0f), + 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 diff --git a/demo/Blackholio/client-godot/FoodController.cs.uid b/demo/Blackholio/client-godot/FoodController.cs.uid new file mode 100644 index 00000000000..48205309e59 --- /dev/null +++ b/demo/Blackholio/client-godot/FoodController.cs.uid @@ -0,0 +1 @@ +uid://cr4egm415cpw4 diff --git a/demo/Blackholio/client-godot/GameManager.cs b/demo/Blackholio/client-godot/GameManager.cs new file mode 100644 index 00000000000..c39c37507bf --- /dev/null +++ b/demo/Blackholio/client-godot/GameManager.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using SpacetimeDB; +using SpacetimeDB.Types; +using Godot; + +public partial class GameManager : Node +{ + public static event Action OnConnected; + public static event Action OnSubscriptionApplied; + + [Export] + private string ServerUrl { get; set; } = "http://127.0.0.1:3000"; + + [Export] + private string DatabaseName { get; set; } = "blackholio"; + + [Export] + private Color BackgroundColor { get; set; } = Colors.MidnightBlue; + + [Export] + private float BorderThickness { get; set; } = 5.0f; + + [Export] + private Color BorderColor { get; set; } = Colors.Goldenrod; + + [Export] + private string DefaultPlayerName { get; set; } = "3Blave"; + + private static GameManager Instance { get; set; } + public static Identity LocalIdentity { get; private set; } + public static DbConnection Conn { get; private set; } + + public GameManager() + { + var builder = DbConnection.Builder() + .OnConnect(HandleConnect) + .OnConnectError(HandleConnectError) + .OnDisconnect(HandleDisconnect) + .WithUri(ServerUrl) + .WithDatabaseName(DatabaseName); + + if (AuthToken.TryGetToken(out var authToken)) + { + builder = builder.WithToken(authToken); + } + + Conn = builder.Build(); + STDBUpdateManager.Add(Conn); + } + + public override void _EnterTree() + { + Instance = this; + } + + public override void _ExitTree() + { + Disconnect(); + + if (Instance == this) + { + Instance = null; + } + } + + public static bool IsConnected() => Conn != null && Conn.IsActive; + + private void Disconnect() + { + STDBUpdateManager.Remove(Conn, true); + Conn = null; + } + + // Called when we connect to SpacetimeDB and receive our client identity + private void HandleConnect(DbConnection conn, Identity identity, string token) + { + GD.Print("Connected."); + AuthToken.SaveToken(token); + LocalIdentity = identity; + + OnConnected?.Invoke(); + + AddChild(new Instantiator(conn)); + + // Request all tables + Conn.SubscriptionBuilder() + .OnApplied(HandleSubscriptionApplied) + .SubscribeToAllTables(); + } + + private void HandleConnectError(Exception ex) + { + GD.PrintErr($"Connection error: {ex}"); + } + + private void HandleDisconnect(DbConnection _conn, Exception ex) + { + GD.Print("Disconnected."); + if (ex != null) + { + GD.PrintErr(ex); + } + } + + private void HandleSubscriptionApplied(SubscriptionEventContext ctx) + { + GD.Print("Subscription applied!"); + OnSubscriptionApplied?.Invoke(); + + // Once we have the initial subscription sync'd to the client cache + // 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); + } + + 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 + }; + background.AddChild(new Polygon2D + { + Name = "Border", + Color = BorderColor, + Position = Vector2.Zero, + InvertEnabled = true, + InvertBorder = BorderThickness, + Polygon = polygon + }); + AddChild(background, @internal: InternalMode.Front); + + AddChild(new CameraController(worldSize)); + } +} diff --git a/demo/Blackholio/client-godot/GameManager.cs.uid b/demo/Blackholio/client-godot/GameManager.cs.uid new file mode 100644 index 00000000000..c8124bc6265 --- /dev/null +++ b/demo/Blackholio/client-godot/GameManager.cs.uid @@ -0,0 +1 @@ +uid://ce8dl6ahr07ou diff --git a/demo/Blackholio/client-godot/Instantiator.cs b/demo/Blackholio/client-godot/Instantiator.cs new file mode 100644 index 00000000000..6d75a416466 --- /dev/null +++ b/demo/Blackholio/client-godot/Instantiator.cs @@ -0,0 +1,142 @@ +using System.Collections.Generic; +using Godot; +using SpacetimeDB.Types; + +public partial class Instantiator : Node +{ + private DbConnection _conn; + private DbConnection Conn + { + get => _conn; + set + { + if (value == _conn) return; + + if (_conn != null) + { + _conn.Db.Circle.OnInsert -= CircleOnInsert; + _conn.Db.Entity.OnUpdate -= EntityOnUpdate; + _conn.Db.Entity.OnDelete -= EntityOnDelete; + _conn.Db.Food.OnInsert -= FoodOnInsert; + _conn.Db.Player.OnInsert -= PlayerOnInsert; + _conn.Db.Player.OnDelete -= PlayerOnDelete; + } + + _conn = value; + + if (value != null) + { + value.Db.Circle.OnInsert += CircleOnInsert; + value.Db.Entity.OnUpdate += EntityOnUpdate; + value.Db.Entity.OnDelete += EntityOnDelete; + value.Db.Food.OnInsert += FoodOnInsert; + value.Db.Player.OnInsert += PlayerOnInsert; + value.Db.Player.OnDelete += PlayerOnDelete; + } + } + } + + private static Dictionary Entities { get; } = new(); + private static Dictionary Players { get; } = new(); + + public Instantiator(DbConnection conn) + { + Conn = conn; + } + + public override void _ExitTree() + { + GD.PrintErr("Instantiator Exit Tree"); + Conn = null; + } + + private void CircleOnInsert(EventContext context, Circle insertedValue) + { + var player = GetOrCreatePlayer(insertedValue.PlayerId); + var entityController = SpawnCircle(insertedValue, player); + Entities[insertedValue.EntityId] = entityController; + } + + private void EntityOnUpdate(EventContext context, Entity oldEntity, Entity newEntity) + { + if (Entities.TryGetValue(newEntity.EntityId, out var entityController)) + { + entityController.OnEntityUpdated(newEntity); + } + } + + private void EntityOnDelete(EventContext context, Entity oldEntity) + { + if (Entities.Remove(oldEntity.EntityId, out var entityController)) + { + entityController.OnDelete(); + } + } + + private void FoodOnInsert(EventContext context, Food insertedValue) + { + var entityController = SpawnFood(insertedValue); + Entities[insertedValue.EntityId] = entityController; + } + + private void PlayerOnInsert(EventContext context, Player insertedPlayer) + { + GetOrCreatePlayer(insertedPlayer.PlayerId); + } + + private void PlayerOnDelete(EventContext context, Player deletedValue) + { + if (Players.Remove(deletedValue.PlayerId, out var playerController)) + { + playerController.QueueFree(); + } + } + + private PlayerController GetOrCreatePlayer(int playerId) + { + if (!Players.TryGetValue(playerId, out var playerController)) + { + var player = Conn.Db.Player.PlayerId.Find(playerId); + playerController = SpawnPlayer(player); + Players[playerId] = playerController; + } + + return playerController; + } + + private CircleController SpawnCircle(Circle circle, PlayerController owner) + { + var entityController = new CircleController(circle, owner) + { + Name = $"Circle - {circle.EntityId}", + }; + + AddChild(entityController); + + return entityController; + } + + private FoodController SpawnFood(Food food) + { + var entityController = new FoodController(food) + { + Name = $"Food - {food.EntityId}", + }; + + AddChild(entityController); + + return entityController; + } + + private PlayerController SpawnPlayer(Player player) + { + var playerController = new PlayerController(player) + { + Name = $"Player - {player.Name}" + }; + + AddChild(playerController); + + return playerController; + } +} \ No newline at end of file diff --git a/demo/Blackholio/client-godot/Instantiator.cs.uid b/demo/Blackholio/client-godot/Instantiator.cs.uid new file mode 100644 index 00000000000..7609abb6396 --- /dev/null +++ b/demo/Blackholio/client-godot/Instantiator.cs.uid @@ -0,0 +1 @@ +uid://g80425tangpw diff --git a/demo/Blackholio/client-godot/PlayerController.cs b/demo/Blackholio/client-godot/PlayerController.cs new file mode 100644 index 00000000000..6dd2b4d8566 --- /dev/null +++ b/demo/Blackholio/client-godot/PlayerController.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using System.Linq; +using Godot; +using SpacetimeDB.Types; + +public partial class PlayerController : Node +{ + const int SEND_UPDATES_PER_SEC = 20; + const float SEND_UPDATES_FREQUENCY = 1f / SEND_UPDATES_PER_SEC; + + public static PlayerController Local { get; private set; } + + private int _playerId; + private float _lastMovementSendTimestamp; + private Vector2? _lockInputPosition; + private readonly List _ownedCircles = new(); + + private bool _lockInputTogglePressed; + + public string Username => GameManager.Conn.Db.Player.PlayerId.Find(_playerId).Name; + public int NumberOfOwnedCircles => _ownedCircles.Count; + public bool IsLocalPlayer => this == Local; + + public PlayerController(Player player) + { + _playerId = player.PlayerId; + if (player.Identity == GameManager.LocalIdentity) + { + Local = this; + } + } + + public override void _ExitTree() + { + foreach (var circle in _ownedCircles.ToList()) + { + if (IsInstanceValid(circle)) + { + circle.QueueFree(); + } + } + + _ownedCircles.Clear(); + if (Local == this) + { + Local = null; + } + } + + public void OnCircleSpawned(CircleController circle) + { + _ownedCircles.Add(circle); + } + + public void OnCircleDeleted(CircleController deletedCircle) + { + _ownedCircles.Remove(deletedCircle); + } + + public int TotalMass() => _ownedCircles + .Select(circle => GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId)) + .Sum(entity => entity?.Mass ?? 0); + + public bool TryGetCenterOfMass(out Vector2 centerOfMass) + { + if (_ownedCircles.Count == 0) + { + centerOfMass = Vector2.Zero; + return false; + } + + var totalPos = Vector2.Zero; + var totalMass = 0.0f; + foreach (var circle in _ownedCircles) + { + var entity = GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId); + if (entity == null) continue; + + totalPos += circle.GlobalPosition * entity.Mass; + totalMass += entity.Mass; + } + + if (totalMass <= 0) + { + centerOfMass = Vector2.Zero; + return false; + } + + centerOfMass = totalPos / totalMass; + return true; + } + + public override void _Process(double delta) + { + if (!IsLocalPlayer || NumberOfOwnedCircles == 0 || !GameManager.IsConnected()) return; + + var lockTogglePressed = Input.IsPhysicalKeyPressed(Key.Q); + if (lockTogglePressed && !_lockInputTogglePressed) + { + if (_lockInputPosition.HasValue) + { + _lockInputPosition = null; + } + else + { + _lockInputPosition = GetViewport().GetMousePosition(); + } + } + _lockInputTogglePressed = lockTogglePressed; + + var nowSeconds = Time.GetTicksMsec() / 1000.0f; + if (nowSeconds - _lastMovementSendTimestamp < SEND_UPDATES_FREQUENCY) return; + + _lastMovementSendTimestamp = nowSeconds; + + var mousePosition = _lockInputPosition ?? GetViewport().GetMousePosition(); + var screenSize = GetViewport().GetVisibleRect().Size; + var centerOfScreen = screenSize / 2.0f; + var direction = (mousePosition - centerOfScreen) / (screenSize.Y / 3.0f); + + GameManager.Conn.Reducers.UpdatePlayerInput(direction); + } +} \ No newline at end of file diff --git a/demo/Blackholio/client-godot/PlayerController.cs.uid b/demo/Blackholio/client-godot/PlayerController.cs.uid new file mode 100644 index 00000000000..993ef27fdf5 --- /dev/null +++ b/demo/Blackholio/client-godot/PlayerController.cs.uid @@ -0,0 +1 @@ +uid://x3x73eqe4x28 diff --git a/demo/Blackholio/client-godot/client-godot.csproj b/demo/Blackholio/client-godot/client-godot.csproj new file mode 100644 index 00000000000..0010196c1af --- /dev/null +++ b/demo/Blackholio/client-godot/client-godot.csproj @@ -0,0 +1,11 @@ + + + net8.0 + net9.0 + true + clientgodot + + + + + \ No newline at end of file diff --git a/demo/Blackholio/client-godot/icon.svg b/demo/Blackholio/client-godot/icon.svg new file mode 100644 index 00000000000..c6bbb7d820d --- /dev/null +++ b/demo/Blackholio/client-godot/icon.svg @@ -0,0 +1 @@ + diff --git a/demo/Blackholio/client-godot/icon.svg.import b/demo/Blackholio/client-godot/icon.svg.import new file mode 100644 index 00000000000..d18dbf4ff0c --- /dev/null +++ b/demo/Blackholio/client-godot/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ckfrm5vae4u3p" +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/uastc_level=0 +compress/rdo_quality_loss=0.0 +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/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +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/demo/Blackholio/client-godot/main.tscn b/demo/Blackholio/client-godot/main.tscn new file mode 100644 index 00000000000..0d852591ef6 --- /dev/null +++ b/demo/Blackholio/client-godot/main.tscn @@ -0,0 +1,8 @@ +[gd_scene format=3 uid="uid://bu0hxewrjspqk"] + +[ext_resource type="Script" uid="uid://ce8dl6ahr07ou" path="res://GameManager.cs" id="1_ig7tw"] + +[node name="Main" type="Node2D" unique_id=1765328469] +script = ExtResource("1_ig7tw") +ServerUrl = "https://maincloud.spacetimedb.com" +DatabaseName = "lis-blackholio-godot" diff --git a/demo/Blackholio/client-godot/module_bindings/Reducers/EnterGame.g.cs b/demo/Blackholio/client-godot/module_bindings/Reducers/EnterGame.g.cs new file mode 100644 index 00000000000..136542a8e57 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Reducers/EnterGame.g.cs @@ -0,0 +1,67 @@ +// 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 EnterGameHandler(ReducerEventContext ctx, string name); + public event EnterGameHandler? OnEnterGame; + + public void EnterGame(string name) + { + conn.InternalCallReducer(new Reducer.EnterGame(name)); + } + + public bool InvokeEnterGame(ReducerEventContext ctx, Reducer.EnterGame args) + { + if (OnEnterGame == 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; + } + OnEnterGame( + ctx, + args.Name + ); + return true; + } + } + + public abstract partial class Reducer + { + [SpacetimeDB.Type] + [DataContract] + public sealed partial class EnterGame : Reducer, IReducerArgs + { + [DataMember(Name = "name")] + public string Name; + + public EnterGame(string Name) + { + this.Name = Name; + } + + public EnterGame() + { + this.Name = ""; + } + + string IReducerArgs.ReducerName => "enter_game"; + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Reducers/EnterGame.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Reducers/EnterGame.g.cs.uid new file mode 100644 index 00000000000..91c04f6b64a --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Reducers/EnterGame.g.cs.uid @@ -0,0 +1 @@ +uid://dwyi6cflmpxxg diff --git a/demo/Blackholio/client-godot/module_bindings/Reducers/UpdatePlayerInput.g.cs b/demo/Blackholio/client-godot/module_bindings/Reducers/UpdatePlayerInput.g.cs new file mode 100644 index 00000000000..612397e3e9c --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Reducers/UpdatePlayerInput.g.cs @@ -0,0 +1,67 @@ +// 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 UpdatePlayerInputHandler(ReducerEventContext ctx, SpacetimeDB.Types.DbVector2 direction); + public event UpdatePlayerInputHandler? OnUpdatePlayerInput; + + public void UpdatePlayerInput(SpacetimeDB.Types.DbVector2 direction) + { + conn.InternalCallReducer(new Reducer.UpdatePlayerInput(direction)); + } + + public bool InvokeUpdatePlayerInput(ReducerEventContext ctx, Reducer.UpdatePlayerInput args) + { + if (OnUpdatePlayerInput == 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; + } + OnUpdatePlayerInput( + ctx, + args.Direction + ); + return true; + } + } + + public abstract partial class Reducer + { + [SpacetimeDB.Type] + [DataContract] + public sealed partial class UpdatePlayerInput : Reducer, IReducerArgs + { + [DataMember(Name = "direction")] + public DbVector2 Direction; + + public UpdatePlayerInput(DbVector2 Direction) + { + this.Direction = Direction; + } + + public UpdatePlayerInput() + { + this.Direction = new(); + } + + string IReducerArgs.ReducerName => "update_player_input"; + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Reducers/UpdatePlayerInput.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Reducers/UpdatePlayerInput.g.cs.uid new file mode 100644 index 00000000000..b4df7bd98c0 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Reducers/UpdatePlayerInput.g.cs.uid @@ -0,0 +1 @@ +uid://bwvobqbvsrp4w diff --git a/demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs b/demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs new file mode 100644 index 00000000000..8a49f001f5e --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs @@ -0,0 +1,641 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d). + +#nullable enable + +using System; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteReducers : RemoteBase + { + internal RemoteReducers(DbConnection conn) : base(conn) { } + internal event Action? InternalOnUnhandledReducerError; + } + + public sealed partial class RemoteProcedures : RemoteBase + { + internal RemoteProcedures(DbConnection conn) : base(conn) { } + } + + public sealed partial class RemoteTables : RemoteTablesBase + { + public RemoteTables(DbConnection conn) + { + AddTable(Circle = new(conn)); + AddTable(Config = new(conn)); + AddTable(Entity = new(conn)); + AddTable(Food = new(conn)); + AddTable(Player = new(conn)); + } + } + + + public interface IRemoteDbContext : IDbContext + { + public event Action? OnUnhandledReducerError; + } + + public sealed class EventContext : IEventContext, IRemoteDbContext + { + private readonly DbConnection conn; + + /// + /// The event that caused this callback to run. + /// + public readonly Event Event; + + /// + /// Access to tables in the client cache, which stores a read-only replica of the remote database state. + /// + /// The returned DbView will have a method to access each table defined by the module. + /// + public RemoteTables Db => conn.Db; + /// + /// Access to reducers defined by the module. + /// + /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, + /// plus methods for adding and removing callbacks on each of those reducers. + /// + public RemoteReducers Reducers => conn.Reducers; + /// + /// Access to procedures defined by the module. + /// + /// The returned RemoteProcedures will have a method to invoke each procedure defined by the module, + /// with a callback for when the procedure completes and returns a value. + /// + public RemoteProcedures Procedures => conn.Procedures; + /// + /// Returns true if the connection is active, i.e. has not yet disconnected. + /// + public bool IsActive => conn.IsActive; + /// + /// Close the connection. + /// + /// Throws an error if the connection is already closed. + /// + public void Disconnect() + { + conn.Disconnect(); + } + /// + /// Start building a subscription. + /// + /// A builder-pattern constructor for subscribing to queries, + /// causing matching rows to be replicated into the client cache. + public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); + /// + /// Get the Identity of this connection. + /// + /// This method returns null if the connection was constructed anonymously + /// and we have not yet received our newly-generated Identity from the host. + /// + public Identity? Identity => conn.Identity; + /// + /// Get this connection's ConnectionId. + /// + public ConnectionId ConnectionId => conn.ConnectionId; + /// + /// Register a callback to be called when a reducer with no handler returns an error. + /// + public event Action? OnUnhandledReducerError + { + add => Reducers.InternalOnUnhandledReducerError += value; + remove => Reducers.InternalOnUnhandledReducerError -= value; + } + + internal EventContext(DbConnection conn, Event Event) + { + this.conn = conn; + this.Event = Event; + } + } + + public sealed class ReducerEventContext : IReducerEventContext, IRemoteDbContext + { + private readonly DbConnection conn; + /// + /// The reducer event that caused this callback to run. + /// + public readonly ReducerEvent Event; + + /// + /// Access to tables in the client cache, which stores a read-only replica of the remote database state. + /// + /// The returned DbView will have a method to access each table defined by the module. + /// + public RemoteTables Db => conn.Db; + /// + /// Access to reducers defined by the module. + /// + /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, + /// plus methods for adding and removing callbacks on each of those reducers. + /// + public RemoteReducers Reducers => conn.Reducers; + /// + /// Access to procedures defined by the module. + /// + /// The returned RemoteProcedures will have a method to invoke each procedure defined by the module, + /// with a callback for when the procedure completes and returns a value. + /// + public RemoteProcedures Procedures => conn.Procedures; + /// + /// Returns true if the connection is active, i.e. has not yet disconnected. + /// + public bool IsActive => conn.IsActive; + /// + /// Close the connection. + /// + /// Throws an error if the connection is already closed. + /// + public void Disconnect() + { + conn.Disconnect(); + } + /// + /// Start building a subscription. + /// + /// A builder-pattern constructor for subscribing to queries, + /// causing matching rows to be replicated into the client cache. + public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); + /// + /// Get the Identity of this connection. + /// + /// This method returns null if the connection was constructed anonymously + /// and we have not yet received our newly-generated Identity from the host. + /// + public Identity? Identity => conn.Identity; + /// + /// Get this connection's ConnectionId. + /// + public ConnectionId ConnectionId => conn.ConnectionId; + /// + /// Register a callback to be called when a reducer with no handler returns an error. + /// + public event Action? OnUnhandledReducerError + { + add => Reducers.InternalOnUnhandledReducerError += value; + remove => Reducers.InternalOnUnhandledReducerError -= value; + } + + internal ReducerEventContext(DbConnection conn, ReducerEvent reducerEvent) + { + this.conn = conn; + Event = reducerEvent; + } + } + + public sealed class ErrorContext : IErrorContext, IRemoteDbContext + { + private readonly DbConnection conn; + /// + /// The Exception that caused this error callback to be run. + /// + public readonly Exception Event; + Exception IErrorContext.Event + { + get + { + return Event; + } + } + + /// + /// Access to tables in the client cache, which stores a read-only replica of the remote database state. + /// + /// The returned DbView will have a method to access each table defined by the module. + /// + public RemoteTables Db => conn.Db; + /// + /// Access to reducers defined by the module. + /// + /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, + /// plus methods for adding and removing callbacks on each of those reducers. + /// + public RemoteReducers Reducers => conn.Reducers; + /// + /// Access to procedures defined by the module. + /// + /// The returned RemoteProcedures will have a method to invoke each procedure defined by the module, + /// with a callback for when the procedure completes and returns a value. + /// + public RemoteProcedures Procedures => conn.Procedures; + /// + /// Returns true if the connection is active, i.e. has not yet disconnected. + /// + public bool IsActive => conn.IsActive; + /// + /// Close the connection. + /// + /// Throws an error if the connection is already closed. + /// + public void Disconnect() + { + conn.Disconnect(); + } + /// + /// Start building a subscription. + /// + /// A builder-pattern constructor for subscribing to queries, + /// causing matching rows to be replicated into the client cache. + public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); + /// + /// Get the Identity of this connection. + /// + /// This method returns null if the connection was constructed anonymously + /// and we have not yet received our newly-generated Identity from the host. + /// + public Identity? Identity => conn.Identity; + /// + /// Get this connection's ConnectionId. + /// + public ConnectionId ConnectionId => conn.ConnectionId; + /// + /// Register a callback to be called when a reducer with no handler returns an error. + /// + public event Action? OnUnhandledReducerError + { + add => Reducers.InternalOnUnhandledReducerError += value; + remove => Reducers.InternalOnUnhandledReducerError -= value; + } + + internal ErrorContext(DbConnection conn, Exception error) + { + this.conn = conn; + Event = error; + } + } + + public sealed class SubscriptionEventContext : ISubscriptionEventContext, IRemoteDbContext + { + private readonly DbConnection conn; + + /// + /// Access to tables in the client cache, which stores a read-only replica of the remote database state. + /// + /// The returned DbView will have a method to access each table defined by the module. + /// + public RemoteTables Db => conn.Db; + /// + /// Access to reducers defined by the module. + /// + /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, + /// plus methods for adding and removing callbacks on each of those reducers. + /// + public RemoteReducers Reducers => conn.Reducers; + /// + /// Access to procedures defined by the module. + /// + /// The returned RemoteProcedures will have a method to invoke each procedure defined by the module, + /// with a callback for when the procedure completes and returns a value. + /// + public RemoteProcedures Procedures => conn.Procedures; + /// + /// Returns true if the connection is active, i.e. has not yet disconnected. + /// + public bool IsActive => conn.IsActive; + /// + /// Close the connection. + /// + /// Throws an error if the connection is already closed. + /// + public void Disconnect() + { + conn.Disconnect(); + } + /// + /// Start building a subscription. + /// + /// A builder-pattern constructor for subscribing to queries, + /// causing matching rows to be replicated into the client cache. + public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); + /// + /// Get the Identity of this connection. + /// + /// This method returns null if the connection was constructed anonymously + /// and we have not yet received our newly-generated Identity from the host. + /// + public Identity? Identity => conn.Identity; + /// + /// Get this connection's ConnectionId. + /// + public ConnectionId ConnectionId => conn.ConnectionId; + /// + /// Register a callback to be called when a reducer with no handler returns an error. + /// + public event Action? OnUnhandledReducerError + { + add => Reducers.InternalOnUnhandledReducerError += value; + remove => Reducers.InternalOnUnhandledReducerError -= value; + } + + internal SubscriptionEventContext(DbConnection conn) + { + this.conn = conn; + } + } + + public sealed class ProcedureEventContext : IProcedureEventContext, IRemoteDbContext + { + private readonly DbConnection conn; + /// + /// The procedure event that caused this callback to run. + /// + public readonly ProcedureEvent Event; + + /// + /// Access to tables in the client cache, which stores a read-only replica of the remote database state. + /// + /// The returned DbView will have a method to access each table defined by the module. + /// + public RemoteTables Db => conn.Db; + /// + /// Access to reducers defined by the module. + /// + /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, + /// plus methods for adding and removing callbacks on each of those reducers. + /// + public RemoteReducers Reducers => conn.Reducers; + /// + /// Access to procedures defined by the module. + /// + /// The returned RemoteProcedures will have a method to invoke each procedure defined by the module, + /// with a callback for when the procedure completes and returns a value. + /// + public RemoteProcedures Procedures => conn.Procedures; + /// + /// Returns true if the connection is active, i.e. has not yet disconnected. + /// + public bool IsActive => conn.IsActive; + /// + /// Close the connection. + /// + /// Throws an error if the connection is already closed. + /// + public void Disconnect() + { + conn.Disconnect(); + } + /// + /// Start building a subscription. + /// + /// A builder-pattern constructor for subscribing to queries, + /// causing matching rows to be replicated into the client cache. + public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); + /// + /// Get the Identity of this connection. + /// + /// This method returns null if the connection was constructed anonymously + /// and we have not yet received our newly-generated Identity from the host. + /// + public Identity? Identity => conn.Identity; + /// + /// Get this connection's ConnectionId. + /// + public ConnectionId ConnectionId => conn.ConnectionId; + /// + /// Register a callback to be called when a reducer with no handler returns an error. + /// + public event Action? OnUnhandledReducerError + { + add => Reducers.InternalOnUnhandledReducerError += value; + remove => Reducers.InternalOnUnhandledReducerError -= value; + } + + internal ProcedureEventContext(DbConnection conn, ProcedureEvent Event) + { + this.conn = conn; + this.Event = Event; + } + } + + /// + /// Builder-pattern constructor for subscription queries. + /// + public sealed class SubscriptionBuilder + { + private readonly IDbConnection conn; + + private event Action? Applied; + private event Action? Error; + + /// + /// Private API, use conn.SubscriptionBuilder() instead. + /// + public SubscriptionBuilder(IDbConnection conn) + { + this.conn = conn; + } + + /// + /// Register a callback to run when the subscription is applied. + /// + public SubscriptionBuilder OnApplied( + Action callback + ) + { + Applied += callback; + return this; + } + + /// + /// Register a callback to run when the subscription fails. + /// + /// Note that this callback may run either when attempting to apply the subscription, + /// in which case Self::on_applied will never run, + /// or later during the subscription's lifetime if the module's interface changes, + /// in which case Self::on_applied may have already run. + /// + public SubscriptionBuilder OnError( + Action callback + ) + { + Error += callback; + return this; + } + + /// + /// Add a typed query to this subscription. + /// + /// This is the entry point for building subscriptions without writing SQL by hand. + /// Once a typed query is added, only typed queries may follow (SQL and typed queries cannot be mixed). + /// + public TypedSubscriptionBuilder AddQuery( + Func> build + ) + { + var typed = new TypedSubscriptionBuilder(conn, Applied, Error); + return typed.AddQuery(build); + } + + /// + /// Subscribe to the following SQL queries. + /// + /// This method returns immediately, with the data not yet added to the DbConnection. + /// The provided callbacks will be invoked once the data is returned from the remote server. + /// Data from all the provided queries will be returned at the same time. + /// + /// See the SpacetimeDB SQL docs for more information on SQL syntax: + /// https://spacetimedb.com/docs/sql + /// + public SubscriptionHandle Subscribe( + string[] querySqls + ) => new(conn, Applied, Error, querySqls); + + /// + /// Subscribe to all rows from all tables. + /// + /// This method is intended as a convenience + /// for applications where client-side memory use and network bandwidth are not concerns. + /// Applications where these resources are a constraint + /// should register more precise queries via Self.Subscribe + /// in order to replicate only the subset of data which the client needs to function. + /// + /// This method should not be combined with Self.Subscribe on the same DbConnection. + /// A connection may either Self.Subscribe to particular queries, + /// or Self.SubscribeToAllTables, but not both. + /// Attempting to call Self.Subscribe + /// on a DbConnection that has previously used Self.SubscribeToAllTables, + /// or vice versa, may misbehave in any number of ways, + /// including dropping subscriptions, corrupting the client cache, or panicking. + /// + public SubscriptionHandle SubscribeToAllTables() => + new(conn, Applied, Error, QueryBuilder.AllTablesSqlQueries()); + } + + public sealed class SubscriptionHandle : SubscriptionHandleBase + { + /// + /// Internal API. Construct SubscriptionHandles using conn.SubscriptionBuilder. + /// + public SubscriptionHandle( + IDbConnection conn, + Action? onApplied, + Action? onError, + string[] querySqls + ) : base(conn, onApplied, onError, querySqls) + { } + } + + public sealed class QueryBuilder + { + public From From { get; } = new(); + + internal static string[] AllTablesSqlQueries() => new string[] + { + new QueryBuilder().From.Circle().ToSql(), + new QueryBuilder().From.Config().ToSql(), + new QueryBuilder().From.Entity().ToSql(), + new QueryBuilder().From.Food().ToSql(), + new QueryBuilder().From.Player().ToSql(), + } + ; + } + + 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 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")); + } + + public sealed class TypedSubscriptionBuilder + { + private readonly IDbConnection conn; + private Action? Applied; + private Action? Error; + private readonly List querySqls = new(); + + internal TypedSubscriptionBuilder(IDbConnection conn, Action? applied, Action? error) + { + this.conn = conn; + Applied = applied; + Error = error; + } + + public TypedSubscriptionBuilder OnApplied(Action callback) + { + Applied += callback; + return this; + } + + public TypedSubscriptionBuilder OnError(Action callback) + { + Error += callback; + return this; + } + + public TypedSubscriptionBuilder AddQuery(Func> build) + { + var qb = new QueryBuilder(); + querySqls.Add(build(qb).ToSql()); + return this; + } + + public SubscriptionHandle Subscribe() => new(conn, Applied, Error, querySqls.ToArray()); + } + + public abstract partial class Reducer + { + private Reducer() { } + } + + public abstract partial class Procedure + { + private Procedure() { } + } + + public sealed class DbConnection : DbConnectionBase + { + public override RemoteTables Db { get; } + public readonly RemoteReducers Reducers; + public readonly RemoteProcedures Procedures; + + public DbConnection() + { + Db = new(this); + Reducers = new(this); + Procedures = new(this); + } + + protected override IEventContext ToEventContext(Event Event) => + new EventContext(this, Event); + + protected override IReducerEventContext ToReducerEventContext(ReducerEvent reducerEvent) => + new ReducerEventContext(this, reducerEvent); + + protected override ISubscriptionEventContext MakeSubscriptionEventContext() => + new SubscriptionEventContext(this); + + protected override IErrorContext ToErrorContext(Exception exception) => + new ErrorContext(this, exception); + + protected override IProcedureEventContext ToProcedureEventContext(ProcedureEvent procedureEvent) => + new ProcedureEventContext(this, procedureEvent); + + protected override bool Dispatch(IReducerEventContext context, Reducer reducer) + { + var eventContext = (ReducerEventContext)context; + return reducer switch + { + Reducer.EnterGame args => Reducers.InvokeEnterGame(eventContext, args), + Reducer.UpdatePlayerInput args => Reducers.InvokeUpdatePlayerInput(eventContext, args), + _ => throw new ArgumentOutOfRangeException("Reducer", $"Unknown reducer {reducer}") + }; + } + + public SubscriptionBuilder SubscriptionBuilder() => new(this); + public event Action OnUnhandledReducerError + { + add => Reducers.InternalOnUnhandledReducerError += value; + remove => Reducers.InternalOnUnhandledReducerError -= value; + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs.uid new file mode 100644 index 00000000000..00b49ce587b --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs.uid @@ -0,0 +1 @@ +uid://c0gmhdkyjh2xs diff --git a/demo/Blackholio/client-godot/module_bindings/Tables/Circle.g.cs b/demo/Blackholio/client-godot/module_bindings/Tables/Circle.g.cs new file mode 100644 index 00000000000..8ae958b6c7a --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Circle.g.cs @@ -0,0 +1,79 @@ +// 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 CircleHandle : RemoteTableHandle + { + protected override string RemoteTableName => "circle"; + + public sealed class EntityIdUniqueIndex : UniqueIndexBase + { + protected override int GetKey(Circle row) => row.EntityId; + + public EntityIdUniqueIndex(CircleHandle table) : base(table) { } + } + + public readonly EntityIdUniqueIndex EntityId; + + public sealed class PlayerIdIndex : BTreeIndexBase + { + protected override int GetKey(Circle row) => row.PlayerId; + + public PlayerIdIndex(CircleHandle table) : base(table) { } + } + + public readonly PlayerIdIndex PlayerId; + + internal CircleHandle(DbConnection conn) : base(conn) + { + EntityId = new(this); + PlayerId = new(this); + } + + protected override object GetPrimaryKey(Circle row) => row.EntityId; + } + + public readonly CircleHandle Circle; + } + + public sealed class CircleCols + { + public global::SpacetimeDB.Col EntityId { get; } + public global::SpacetimeDB.Col PlayerId { get; } + public global::SpacetimeDB.Col Direction { get; } + public global::SpacetimeDB.Col Speed { get; } + public global::SpacetimeDB.Col LastSplitTime { get; } + + public CircleCols(string tableName) + { + EntityId = new global::SpacetimeDB.Col(tableName, "entity_id"); + PlayerId = new global::SpacetimeDB.Col(tableName, "player_id"); + Direction = new global::SpacetimeDB.Col(tableName, "direction"); + Speed = new global::SpacetimeDB.Col(tableName, "speed"); + LastSplitTime = new global::SpacetimeDB.Col(tableName, "last_split_time"); + } + } + + public sealed class CircleIxCols + { + public global::SpacetimeDB.IxCol EntityId { get; } + public global::SpacetimeDB.IxCol PlayerId { get; } + + public CircleIxCols(string tableName) + { + EntityId = new global::SpacetimeDB.IxCol(tableName, "entity_id"); + PlayerId = new global::SpacetimeDB.IxCol(tableName, "player_id"); + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Tables/Circle.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Tables/Circle.g.cs.uid new file mode 100644 index 00000000000..5e2b940978e --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Circle.g.cs.uid @@ -0,0 +1 @@ +uid://dtumpr4bxjf6b diff --git a/demo/Blackholio/client-godot/module_bindings/Tables/Config.g.cs b/demo/Blackholio/client-godot/module_bindings/Tables/Config.g.cs new file mode 100644 index 00000000000..d6340962dee --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Config.g.cs @@ -0,0 +1,61 @@ +// 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 ConfigHandle : RemoteTableHandle + { + protected override string RemoteTableName => "config"; + + public sealed class IdUniqueIndex : UniqueIndexBase + { + protected override int GetKey(Config row) => row.Id; + + public IdUniqueIndex(ConfigHandle table) : base(table) { } + } + + public readonly IdUniqueIndex Id; + + internal ConfigHandle(DbConnection conn) : base(conn) + { + Id = new(this); + } + + protected override object GetPrimaryKey(Config row) => row.Id; + } + + public readonly ConfigHandle Config; + } + + public sealed class ConfigCols + { + public global::SpacetimeDB.Col Id { get; } + public global::SpacetimeDB.Col WorldSize { get; } + + public ConfigCols(string tableName) + { + Id = new global::SpacetimeDB.Col(tableName, "id"); + WorldSize = new global::SpacetimeDB.Col(tableName, "world_size"); + } + } + + public sealed class ConfigIxCols + { + public global::SpacetimeDB.IxCol Id { get; } + + public ConfigIxCols(string tableName) + { + Id = new global::SpacetimeDB.IxCol(tableName, "id"); + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Tables/Config.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Tables/Config.g.cs.uid new file mode 100644 index 00000000000..fe968262c74 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Config.g.cs.uid @@ -0,0 +1 @@ +uid://cfol51xw3dhgy diff --git a/demo/Blackholio/client-godot/module_bindings/Tables/Entity.g.cs b/demo/Blackholio/client-godot/module_bindings/Tables/Entity.g.cs new file mode 100644 index 00000000000..e15bec6af06 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Entity.g.cs @@ -0,0 +1,63 @@ +// 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 EntityHandle : RemoteTableHandle + { + protected override string RemoteTableName => "entity"; + + public sealed class EntityIdUniqueIndex : UniqueIndexBase + { + protected override int GetKey(Entity row) => row.EntityId; + + public EntityIdUniqueIndex(EntityHandle table) : base(table) { } + } + + public readonly EntityIdUniqueIndex EntityId; + + internal EntityHandle(DbConnection conn) : base(conn) + { + EntityId = new(this); + } + + protected override object GetPrimaryKey(Entity row) => row.EntityId; + } + + public readonly EntityHandle Entity; + } + + public sealed class EntityCols + { + public global::SpacetimeDB.Col EntityId { get; } + public global::SpacetimeDB.Col Position { get; } + public global::SpacetimeDB.Col Mass { get; } + + public EntityCols(string tableName) + { + EntityId = new global::SpacetimeDB.Col(tableName, "entity_id"); + Position = new global::SpacetimeDB.Col(tableName, "position"); + Mass = new global::SpacetimeDB.Col(tableName, "mass"); + } + } + + public sealed class EntityIxCols + { + public global::SpacetimeDB.IxCol EntityId { get; } + + public EntityIxCols(string tableName) + { + EntityId = new global::SpacetimeDB.IxCol(tableName, "entity_id"); + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Tables/Entity.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Tables/Entity.g.cs.uid new file mode 100644 index 00000000000..19e779b24fa --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Entity.g.cs.uid @@ -0,0 +1 @@ +uid://c17wdsxgtjdb2 diff --git a/demo/Blackholio/client-godot/module_bindings/Tables/Food.g.cs b/demo/Blackholio/client-godot/module_bindings/Tables/Food.g.cs new file mode 100644 index 00000000000..7b62f84d523 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Food.g.cs @@ -0,0 +1,59 @@ +// 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 FoodHandle : RemoteTableHandle + { + protected override string RemoteTableName => "food"; + + public sealed class EntityIdUniqueIndex : UniqueIndexBase + { + protected override int GetKey(Food row) => row.EntityId; + + public EntityIdUniqueIndex(FoodHandle table) : base(table) { } + } + + public readonly EntityIdUniqueIndex EntityId; + + internal FoodHandle(DbConnection conn) : base(conn) + { + EntityId = new(this); + } + + protected override object GetPrimaryKey(Food row) => row.EntityId; + } + + public readonly FoodHandle Food; + } + + public sealed class FoodCols + { + public global::SpacetimeDB.Col EntityId { get; } + + public FoodCols(string tableName) + { + EntityId = new global::SpacetimeDB.Col(tableName, "entity_id"); + } + } + + public sealed class FoodIxCols + { + public global::SpacetimeDB.IxCol EntityId { get; } + + public FoodIxCols(string tableName) + { + EntityId = new global::SpacetimeDB.IxCol(tableName, "entity_id"); + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Tables/Food.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Tables/Food.g.cs.uid new file mode 100644 index 00000000000..9767ab3ae22 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Food.g.cs.uid @@ -0,0 +1 @@ +uid://bxxge045y3x8s diff --git a/demo/Blackholio/client-godot/module_bindings/Tables/Player.g.cs b/demo/Blackholio/client-godot/module_bindings/Tables/Player.g.cs new file mode 100644 index 00000000000..9250cf376e0 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Player.g.cs @@ -0,0 +1,75 @@ +// 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 PlayerHandle : RemoteTableHandle + { + protected override string RemoteTableName => "player"; + + public sealed class IdentityUniqueIndex : UniqueIndexBase + { + protected override SpacetimeDB.Identity GetKey(Player row) => row.Identity; + + public IdentityUniqueIndex(PlayerHandle table) : base(table) { } + } + + public readonly IdentityUniqueIndex Identity; + + public sealed class PlayerIdUniqueIndex : UniqueIndexBase + { + protected override int GetKey(Player row) => row.PlayerId; + + public PlayerIdUniqueIndex(PlayerHandle table) : base(table) { } + } + + public readonly PlayerIdUniqueIndex PlayerId; + + internal PlayerHandle(DbConnection conn) : base(conn) + { + Identity = new(this); + PlayerId = new(this); + } + + protected override object GetPrimaryKey(Player row) => row.Identity; + } + + public readonly PlayerHandle Player; + } + + public sealed class PlayerCols + { + public global::SpacetimeDB.Col Identity { get; } + public global::SpacetimeDB.Col PlayerId { get; } + public global::SpacetimeDB.Col Name { get; } + + public PlayerCols(string tableName) + { + Identity = new global::SpacetimeDB.Col(tableName, "identity"); + PlayerId = new global::SpacetimeDB.Col(tableName, "player_id"); + Name = new global::SpacetimeDB.Col(tableName, "name"); + } + } + + public sealed class PlayerIxCols + { + public global::SpacetimeDB.IxCol Identity { get; } + public global::SpacetimeDB.IxCol PlayerId { get; } + + public PlayerIxCols(string tableName) + { + Identity = new global::SpacetimeDB.IxCol(tableName, "identity"); + PlayerId = new global::SpacetimeDB.IxCol(tableName, "player_id"); + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Tables/Player.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Tables/Player.g.cs.uid new file mode 100644 index 00000000000..a84eee2afb5 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Player.g.cs.uid @@ -0,0 +1 @@ +uid://byugnhsnblyxe diff --git a/demo/Blackholio/client-godot/module_bindings/Types/Circle.g.cs b/demo/Blackholio/client-godot/module_bindings/Types/Circle.g.cs new file mode 100644 index 00000000000..6d74053cadc --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/Circle.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 System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + [SpacetimeDB.Type] + [DataContract] + public sealed partial class Circle + { + [DataMember(Name = "entity_id")] + public int EntityId; + [DataMember(Name = "player_id")] + public int PlayerId; + [DataMember(Name = "direction")] + public DbVector2 Direction; + [DataMember(Name = "speed")] + public float Speed; + [DataMember(Name = "last_split_time")] + public SpacetimeDB.Timestamp LastSplitTime; + + public Circle( + int EntityId, + int PlayerId, + DbVector2 Direction, + float Speed, + SpacetimeDB.Timestamp LastSplitTime + ) + { + this.EntityId = EntityId; + this.PlayerId = PlayerId; + this.Direction = Direction; + this.Speed = Speed; + this.LastSplitTime = LastSplitTime; + } + + public Circle() + { + this.Direction = new(); + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Types/Circle.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Types/Circle.g.cs.uid new file mode 100644 index 00000000000..685d0d33244 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/Circle.g.cs.uid @@ -0,0 +1 @@ +uid://c5uxjqm2dett7 diff --git a/demo/Blackholio/client-godot/module_bindings/Types/Config.g.cs b/demo/Blackholio/client-godot/module_bindings/Types/Config.g.cs new file mode 100644 index 00000000000..82439095ed5 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/Config.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 Config + { + [DataMember(Name = "id")] + public int Id; + [DataMember(Name = "world_size")] + public long WorldSize; + + public Config( + int Id, + long WorldSize + ) + { + this.Id = Id; + this.WorldSize = WorldSize; + } + + public Config() + { + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Types/Config.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Types/Config.g.cs.uid new file mode 100644 index 00000000000..54cc203106b --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/Config.g.cs.uid @@ -0,0 +1 @@ +uid://bamm7tde4fc6q diff --git a/demo/Blackholio/client-godot/module_bindings/Types/DbVector2.g.cs b/demo/Blackholio/client-godot/module_bindings/Types/DbVector2.g.cs new file mode 100644 index 00000000000..c8667485ab6 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/DbVector2.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 DbVector2 + { + [DataMember(Name = "x")] + public float X; + [DataMember(Name = "y")] + public float Y; + + public DbVector2( + float X, + float Y + ) + { + this.X = X; + this.Y = Y; + } + + public DbVector2() + { + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Types/DbVector2.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Types/DbVector2.g.cs.uid new file mode 100644 index 00000000000..d87b50e465b --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/DbVector2.g.cs.uid @@ -0,0 +1 @@ +uid://b72fw0ktxnllr diff --git a/demo/Blackholio/client-godot/module_bindings/Types/Entity.g.cs b/demo/Blackholio/client-godot/module_bindings/Types/Entity.g.cs new file mode 100644 index 00000000000..0efa0f6ce15 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/Entity.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 Entity + { + [DataMember(Name = "entity_id")] + public int EntityId; + [DataMember(Name = "position")] + public DbVector2 Position; + [DataMember(Name = "mass")] + public int Mass; + + public Entity( + int EntityId, + DbVector2 Position, + int Mass + ) + { + this.EntityId = EntityId; + this.Position = Position; + this.Mass = Mass; + } + + public Entity() + { + this.Position = new(); + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Types/Entity.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Types/Entity.g.cs.uid new file mode 100644 index 00000000000..9290b54152d --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/Entity.g.cs.uid @@ -0,0 +1 @@ +uid://bbqoaj62svbsb diff --git a/demo/Blackholio/client-godot/module_bindings/Types/Food.g.cs b/demo/Blackholio/client-godot/module_bindings/Types/Food.g.cs new file mode 100644 index 00000000000..5bf79dbd7e9 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/Food.g.cs @@ -0,0 +1,28 @@ +// 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 Food + { + [DataMember(Name = "entity_id")] + public int EntityId; + + public Food(int EntityId) + { + this.EntityId = EntityId; + } + + public Food() + { + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Types/Food.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Types/Food.g.cs.uid new file mode 100644 index 00000000000..3508b76ab17 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/Food.g.cs.uid @@ -0,0 +1 @@ +uid://bc53r6j5ea1x5 diff --git a/demo/Blackholio/client-godot/module_bindings/Types/MoveAllPlayersTimer.g.cs b/demo/Blackholio/client-godot/module_bindings/Types/MoveAllPlayersTimer.g.cs new file mode 100644 index 00000000000..a9232130384 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/MoveAllPlayersTimer.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 MoveAllPlayersTimer + { + [DataMember(Name = "scheduled_id")] + public ulong ScheduledId; + [DataMember(Name = "scheduled_at")] + public SpacetimeDB.ScheduleAt ScheduledAt; + + public MoveAllPlayersTimer( + ulong ScheduledId, + SpacetimeDB.ScheduleAt ScheduledAt + ) + { + this.ScheduledId = ScheduledId; + this.ScheduledAt = ScheduledAt; + } + + public MoveAllPlayersTimer() + { + this.ScheduledAt = null!; + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Types/MoveAllPlayersTimer.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Types/MoveAllPlayersTimer.g.cs.uid new file mode 100644 index 00000000000..2d1d4b9123f --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/MoveAllPlayersTimer.g.cs.uid @@ -0,0 +1 @@ +uid://bbopgetgl0ia4 diff --git a/demo/Blackholio/client-godot/module_bindings/Types/Player.g.cs b/demo/Blackholio/client-godot/module_bindings/Types/Player.g.cs new file mode 100644 index 00000000000..48b59304e2c --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/Player.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 Player + { + [DataMember(Name = "identity")] + public SpacetimeDB.Identity Identity; + [DataMember(Name = "player_id")] + public int PlayerId; + [DataMember(Name = "name")] + public string Name; + + public Player( + SpacetimeDB.Identity Identity, + int PlayerId, + string Name + ) + { + this.Identity = Identity; + this.PlayerId = PlayerId; + this.Name = Name; + } + + public Player() + { + this.Name = ""; + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Types/Player.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Types/Player.g.cs.uid new file mode 100644 index 00000000000..df230d821ab --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/Player.g.cs.uid @@ -0,0 +1 @@ +uid://dxabwjoo16o1g diff --git a/demo/Blackholio/client-godot/module_bindings/Types/SpawnFoodTimer.g.cs b/demo/Blackholio/client-godot/module_bindings/Types/SpawnFoodTimer.g.cs new file mode 100644 index 00000000000..90d0fc058b9 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/SpawnFoodTimer.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 SpawnFoodTimer + { + [DataMember(Name = "scheduled_id")] + public ulong ScheduledId; + [DataMember(Name = "scheduled_at")] + public SpacetimeDB.ScheduleAt ScheduledAt; + + public SpawnFoodTimer( + ulong ScheduledId, + SpacetimeDB.ScheduleAt ScheduledAt + ) + { + this.ScheduledId = ScheduledId; + this.ScheduledAt = ScheduledAt; + } + + public SpawnFoodTimer() + { + this.ScheduledAt = null!; + } + } +} diff --git a/demo/Blackholio/client-godot/module_bindings/Types/SpawnFoodTimer.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/Types/SpawnFoodTimer.g.cs.uid new file mode 100644 index 00000000000..23b1a88c154 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/SpawnFoodTimer.g.cs.uid @@ -0,0 +1 @@ +uid://cr6a0dknfjvxt diff --git a/demo/Blackholio/client-godot/project.godot b/demo/Blackholio/client-godot/project.godot new file mode 100644 index 00000000000..d20f769b953 --- /dev/null +++ b/demo/Blackholio/client-godot/project.godot @@ -0,0 +1,30 @@ +; 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="blackholio" +run/main_scene="uid://cjb7808stemnn" +config/features=PackedStringArray("4.6", "C#", "GL Compatibility") +config/icon="res://icon.svg" + +[dotnet] + +project/assembly_name="blackholio" + +[physics] + +3d/physics_engine="Jolt Physics" + +[rendering] + +rendering_device/driver.windows="d3d12" +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00300-part-2.md b/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00300-part-2.md index 3df6b0b735a..1ea168d46d9 100644 --- a/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00300-part-2.md +++ b/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00300-part-2.md @@ -23,12 +23,12 @@ blackholio/ # This is the directory for your Unity project l │ └── module_bindings/ # This directory contains the client logic to communicate with the module ├── Library/ ├── ... # rest of the Unity files -└── spacetimedb/ # This is where your server module lives +└── blackholio-server/ + └── spacetimedb/ # This is where your server module lives ``` Your `module_bindings` directory can go wherever you want as long as it is inside of `Assets/` in your Unity project. We'll configure this in a later step. For now we will create a new module in the `blackholio` directory which will generate the `spacetimedb` directory for us. - ## Create a Server Module If you have not already installed the `spacetime` CLI, check out our [Getting Started](../../00100-getting-started/00100-getting-started.md) guide for instructions on how to install. @@ -47,7 +47,9 @@ Run the following command to initialize the SpacetimeDB server module project wi spacetime init --lang csharp --server-only blackholio ``` -This command creates a new folder named `spacetimedb` inside of your Unity project `blackholio` directory and sets up the SpacetimeDB server project with C# as the programming language. +Use `blackholio-server` for the project path and `blackholio` for the database name. + +This command creates a new folder named `blackholio-server` inside of your Unity project `blackholio` directory. Inside, there's a `spacetimedb` folder that contains the SpacetimeDB server project with C# as the programming language. @@ -57,7 +59,9 @@ Run the following command to initialize the SpacetimeDB server module project wi spacetime init --lang rust --server-only blackholio ``` -This command creates a new folder named `spacetimedb` inside of your Unity project `blackholio` directory and sets up the SpacetimeDB server project with Rust as the programming language. +Use `blackholio-server` for the project path and `blackholio` for the database name. + +This command creates a new folder named `blackholio-server` inside of your Unity project `blackholio` directory. Inside, there's a `spacetimedb` folder that contains the SpacetimeDB server project with Rust as the programming language. @@ -69,7 +73,9 @@ Run the following command to initialize the SpacetimeDB server module project wi spacetime init --lang cpp --server-only blackholio ``` -This command creates a new folder named `spacetimedb` inside of your Unity project `blackholio` directory and sets up the SpacetimeDB server project with C++ as the programming language. +Use `blackholio-server` for the project path and `blackholio` for the database name. + +This command creates a new folder named `blackholio-server` inside of your Unity project `blackholio` directory. Inside, there's a `spacetimedb` folder that contains the SpacetimeDB server project with C++ as the programming language. @@ -77,21 +83,21 @@ This command creates a new folder named `spacetimedb` inside of your Unity proje -In this section we'll be making some edits to the file `blackholio/spacetimedb/Lib.cs`. We recommend you open up this file in an IDE like VSCode or Rider. +In this section we'll be making some edits to the file `blackholio-server/spacetimedb/Lib.cs`. We recommend you open up this file in an IDE like VSCode or Rider. -**Important: Open the `blackholio/spacetimedb/Lib.cs` file and delete its contents. We will be writing it from scratch here.** +**Important: Open the `blackholio-server/spacetimedb/Lib.cs` file and delete its contents. We will be writing it from scratch here.** -In this section we'll be making some edits to the file `blackholio/spacetimedb/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. +In this section we'll be making some edits to the file `blackholio-server/spacetimedb/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. -**Important: Open the `blackholio/spacetimedb/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** +**Important: Open the `blackholio-server/spacetimedb/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** -In this section we'll be making some edits to the file `blackholio/spacetimedb/src/lib.cpp`. We recommend you open up this file in an IDE like VSCode or Rider. +In this section we'll be making some edits to the file `blackholio-server/spacetimedb/src/lib.cpp`. We recommend you open up this file in an IDE like VSCode or Rider. -**Important: Open the `blackholio/spacetimedb/src/lib.cpp` file and delete its contents. We will be writing it from scratch here.** +**Important: Open the `blackholio-server/spacetimedb/src/lib.cpp` file and delete its contents. We will be writing it from scratch here.** @@ -223,7 +229,7 @@ Next, we're going to define a new `SpacetimeType` called `DbVector2` which we're ```csharp // This allows us to store 2D points in tables. -[SpacetimeDB.Type] +[Type] public partial struct DbVector2 { public float x; @@ -258,7 +264,7 @@ public partial struct Circle public int player_id; public DbVector2 direction; public float speed; - public SpacetimeDB.Timestamp last_split_time; + public Timestamp last_split_time; } [Table(Accessor = "food", Public = true)] @@ -373,7 +379,7 @@ The first table we defined is the `entity` table. An entity represents an object We can create different types of entities with additional data by creating new tables with additional fields that have an `entity_id` which references a row in the `entity` table. -We've created two types of entities in our game world: `Food`s and `Circle`s. `Food` does not have any additional fields beyond the attributes in the `entity` table, so the `food` table simply represents the set of `entity_id`s that we want to recognize as food. +We've created two types of entities in our game world: `Food` and `Circle`. `Food` does not have any additional fields beyond the attributes in the `entity` table, so the `food` table simply represents the set of `entity_id`s that we want to recognize as food. The `Circle` table, however, represents an entity that is controlled by a player. We've added a few additional fields to a `Circle` like `player_id` so that we know which player that circle belongs to. @@ -506,7 +512,7 @@ This following log output indicates that SpacetimeDB is successfully running on Starting SpacetimeDB listening on 127.0.0.1:3000 ``` -Now that SpacetimeDB is running we can publish our module to the SpacetimeDB host. In a separate terminal window, navigate to the `blackholio/spacetimedb` directory. +Now that SpacetimeDB is running we can publish our module to the SpacetimeDB host. In a separate terminal window, navigate to the `blackholio-server/spacetimedb` directory. If you are not already logged in to the `spacetime` CLI, run the `spacetime login` command to log in to your SpacetimeDB website account. Once you are logged in, run `spacetime publish --server local blackholio`. This will publish our Blackholio server logic to SpacetimeDB. @@ -528,23 +534,20 @@ Next, use the `spacetime` command to call our newly defined `Debug` reducer: ```sh spacetime call --server local blackholio debug ``` - - Next, use the `spacetime` command to call our newly defined `debug` reducer: ```sh spacetime call --server local blackholio debug ``` - +Next, use the `spacetime` command to call our newly defined `debug` reducer: ```sh spacetime call --server local blackholio debug ``` - @@ -570,7 +573,7 @@ You should see something like the following output: -Next let's connect our client to our database. Let's start by modifying our `Debug` reducer. Rename the reducer to be called `Connect` and add `ReducerKind.ClientConnected` in parentheses after `SpacetimeDB.Reducer`. The end result should look like this: +Next let's connect our client to our database. Let's start by modifying our `Debug` reducer. Rename the reducer to be called `Connect` and add `ReducerKind.ClientConnected` in parentheses after `Reducer`. The end result should look like this: ```csharp [Reducer(ReducerKind.ClientConnected)] @@ -640,25 +643,13 @@ spacetime publish --server local blackholio The `spacetime` CLI has built in functionality to let us generate C# types that correspond to our tables, types, and reducers that we can use from our Unity client. - - - Let's generate our types for our module. In the `blackholio/spacetimedb` - directory run the following command: - - - Let's generate our types for our module. In the `blackholio/spacetimedb` - directory run the following command: - - -Let's generate our types for our module. In the `blackholio/spacetimedb` directory run the following command: - - +Let's generate our types for our module. In the `blackholio-server/spacetimedb` directory run the following command: ```sh -spacetime generate --lang csharp --out-dir ../Assets/module_bindings +spacetime generate --lang csharp --out-dir ../../Assets/module_bindings ``` -This will generate a set of files in the `Assets/module_bindings` directory which contain the code generated types and reducer functions that are defined in your module, but usable on the client. +This will generate a set of files in the `module_bindings` directory (inside your Unity `blackholio` project directory) which contain the code generated types and reducer functions that are defined in your module, but usable on the client. ``` ├── Reducers @@ -678,9 +669,9 @@ This will generate a set of files in the `Assets/module_bindings` directory whic └── SpacetimeDBClient.g.cs ``` -This will also generate a file in the `Assets/autogen/SpacetimeDBClient.g.cs` directory with a type aware `DbConnection` class. We will use this class to connect to your database from Unity. +This will also generate a file `Assets/module_bindings/SpacetimeDBClient.g.cs` with a type aware `DbConnection` class. We will use this class to connect to your database from Unity. -> IMPORTANT! At this point there will be an error in your Unity project. Due to a [known issue](https://docs.unity3d.com/6000.0/Documentation/Manual/csharp-compiler.html) with Unity and C# 9 you need to insert the following code into your Unity project. +> IMPORTANT! At this point there may be an error in your Unity project. Due to a [known issue](https://docs.unity3d.com/6000.0/Documentation/Manual/csharp-compiler.html) with Unity and C# 9 you need to insert the following code into your Unity project. > > ```csharp > namespace System.Runtime.CompilerServices @@ -695,7 +686,7 @@ This will also generate a file in the `Assets/autogen/SpacetimeDBClient.g.cs` di At this point we can set up Unity to connect your Unity client to the server. Replace your imports at the top of the `GameManager.cs` file with: -```cs +```csharp using System; using System.Collections; using System.Collections.Generic; @@ -706,7 +697,7 @@ using UnityEngine; Replace the implementation of the `GameManager` class with the following. -```cs +```csharp public class GameManager : MonoBehaviour { const string SERVER_URL = "http://127.0.0.1:3000"; diff --git a/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00400-part-3.md b/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00400-part-3.md index 8de2e8d1610..612bdb4030f 100644 --- a/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00400-part-3.md +++ b/docs/docs/00100-intro/00300-tutorials/00300-unity-tutorial/00400-part-3.md @@ -21,7 +21,7 @@ Let's start by spawning food into the map. The first thing we need to do is crea Add this new reducer above our `Connect` reducer. ```csharp -// Note the `init` parameter passed to the reducer macro. +// Note the `ReducerKind.Init` parameter passed to the reducer macro. // That indicates to SpacetimeDB that it should be called // once upon database creation. [Reducer(ReducerKind.Init)] @@ -51,25 +51,25 @@ public static void SpawnFood(ReducerContext ctx) return; } - var world_size = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + var worldSize = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; var rng = ctx.Rng; - var food_count = ctx.Db.food.Count; - while (food_count < TARGET_FOOD_COUNT) + var foodCount = ctx.Db.food.Count; + while (foodCount < TARGET_FOOD_COUNT) { - var food_mass = rng.Range(FOOD_MASS_MIN, FOOD_MASS_MAX); - var food_radius = MassToRadius(food_mass); - var x = rng.Range(food_radius, world_size - food_radius); - var y = rng.Range(food_radius, world_size - food_radius); - var entity = ctx.Db.entity.Insert(new Entity() + var foodMass = rng.Range(FOOD_MASS_MIN, FOOD_MASS_MAX); + var foodRadius = MassToRadius(foodMass); + var x = rng.Range(foodRadius, worldSize - foodRadius); + var y = rng.Range(foodRadius, worldSize - foodRadius); + var entity = ctx.Db.entity.Insert(new Entity { position = new DbVector2(x, y), - mass = food_mass, + mass = foodMass, }); ctx.Db.food.Insert(new Food { entity_id = entity.entity_id, }); - food_count++; + foodCount++; Log.Info($"Spawned food! {entity.entity_id}"); } } @@ -85,7 +85,7 @@ We also added two helper functions so we can get a random range as either a `int -Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `init` reducer. SpacetimeDB calls the `init` reducer automatically when first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportunity to initialize the state of your database before any clients connect. +Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `init` reducer. SpacetimeDB calls the `init` reducer automatically when first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportGodot to initialize the state of your database before any clients connect. Add this new reducer above our `connect` reducer. @@ -161,7 +161,7 @@ In this reducer, we are using the `world_size` we configured along with the `Red -Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `SPACETIMEDB_INIT` reducer. SpacetimeDB calls the `SPACETIMEDB_INIT` reducer automatically when you first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportunity to initialize the state of your database before any clients connect. +Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `SPACETIMEDB_INIT` reducer. SpacetimeDB calls the `SPACETIMEDB_INIT` reducer automatically when you first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportGodot to initialize the state of your database before any clients connect. Add this new reducer above our `connect` reducer. @@ -243,7 +243,7 @@ We would like for this function to be called periodically to "top up" the amount -In order to schedule a reducer to be called we have to create a new table which specifies when an how a reducer should be called. Add this new table to the top of the `Module` class. +In order to schedule a reducer to be called we have to create a new table which specifies when and how a reducer should be called. Add this new table to the top of the `Module` class. ```csharp [Table(Accessor = "spawn_food_timer", Scheduled = nameof(SpawnFood), ScheduledAt = nameof(scheduled_at))] @@ -294,10 +294,9 @@ Note the `SPACETIMEDB_SCHEDULE(spawn_food_timer, 1, spawn_food)` call. This tell You can create, delete, or change a schedule by inserting, deleting, or updating rows in this table. -You will see an error telling you that the `spawn_food` reducer needs to take two arguments, but currently only takes one. This is because the schedule row must be passed in to all scheduled reducers. Modify your `spawn_food` reducer to take the scheduled row as an argument. - +You will see an error telling you that the `SpawnFood` reducer needs to take two arguments, but currently only takes one. This is because the schedule row must be passed in to all scheduled reducers. Modify your `SpawnFood` reducer to take the scheduled row as an argument. ```csharp [Reducer] @@ -309,6 +308,7 @@ public static void SpawnFood(ReducerContext ctx, SpawnFoodTimer _timer) +You will see an error telling you that the `spawn_food` reducer needs to take two arguments, but currently only takes one. This is because the schedule row must be passed in to all scheduled reducers. Modify your `spawn_food` reducer to take the scheduled row as an argument. ```rust #[spacetimedb::reducer] @@ -319,6 +319,7 @@ pub fn spawn_food(ctx: &ReducerContext, _timer: SpawnFoodTimer) -> Result<(), St +You will see an error telling you that the `spawn_food` reducer needs to take two arguments, but currently only takes one. This is because the schedule row must be passed in to all scheduled reducers. Modify your `spawn_food` reducer to take the scheduled row as an argument. ```cpp SPACETIMEDB_REDUCER(spawn_food, ReducerContext ctx, SpawnFoodTimer _timer) { @@ -496,7 +497,7 @@ In C++, since we're creating two separate tables from the same struct, we need t -This line creates an additional tabled called `logged_out_player` whose rows share the same `Player` type as in the `player` table. +This creates an additional tabled called `logged_out_player` whose rows share the same `Player` type as in the `player` table. :::note @@ -623,7 +624,7 @@ When a player disconnects, we will transfer their player row from the `player` t :::note -Note that we could have added a `logged_in` boolean to the `Player` type to indicated whether the player is logged in. There's nothing incorrect about that approach, however for several reasons we recommend this two table approach: +Note that we could have added a `logged_in` boolean to the `Player` type to indicate whether the player is logged in. There's nothing incorrect about that approach, however for several reasons we recommend this two table approach: - We can iterate over all logged in players without any `if` statements or branching - The `Player` type now uses less program memory improving cache efficiency @@ -654,23 +655,23 @@ public static void EnterGame(ReducerContext ctx, string name) SpawnPlayerInitialCircle(ctx, player.player_id); } -public static Entity SpawnPlayerInitialCircle(ReducerContext ctx, int player_id) +public static Entity SpawnPlayerInitialCircle(ReducerContext ctx, int playerId) { var rng = ctx.Rng; - var world_size = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; - var player_start_radius = MassToRadius(START_PLAYER_MASS); - var x = rng.Range(player_start_radius, world_size - player_start_radius); - var y = rng.Range(player_start_radius, world_size - player_start_radius); + var worldSize = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + var playerStartRadius = MassToRadius(START_PLAYER_MASS); + var x = rng.Range(playerStartRadius, worldSize - playerStartRadius); + var y = rng.Range(playerStartRadius, worldSize - playerStartRadius); return SpawnCircleAt( ctx, - player_id, + playerId, START_PLAYER_MASS, new DbVector2(x, y), ctx.Timestamp ); } -public static Entity SpawnCircleAt(ReducerContext ctx, int player_id, int mass, DbVector2 position, SpacetimeDB.Timestamp timestamp) +public static Entity SpawnCircleAt(ReducerContext ctx, int playerId, int mass, DbVector2 position, Timestamp timestamp) { var entity = ctx.Db.entity.Insert(new Entity { @@ -681,7 +682,7 @@ public static Entity SpawnCircleAt(ReducerContext ctx, int player_id, int mass, ctx.Db.circle.Insert(new Circle { entity_id = entity.entity_id, - player_id = player_id, + player_id = playerId, direction = new DbVector2(0, 1), speed = 0f, last_split_time = timestamp, @@ -692,6 +693,24 @@ public static Entity SpawnCircleAt(ReducerContext ctx, int player_id, int mass, The `EnterGame` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. +Let's also modify our `Disconnect` reducer to remove the circles from the arena when the player disconnects from the database server. + +```csharp +[Reducer(ReducerKind.ClientDisconnected)] +public static void Disconnect(ReducerContext ctx) +{ + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); + // Remove any circles from the arena + foreach (var circle in ctx.Db.circle.player_id.Filter(player.player_id)) + { + var entity = ctx.Db.entity.entity_id.Find(circle.entity_id) ?? throw new Exception("Could not find circle"); + ctx.Db.entity.entity_id.Delete(entity.entity_id); + ctx.Db.circle.entity_id.Delete(entity.entity_id); + } + ctx.Db.logged_out_player.Insert(player); + ctx.Db.player.identity.Delete(player.identity); +} +``` Add the following to the bottom of your file. @@ -758,6 +777,30 @@ fn spawn_circle_at( The `enter_game` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. +Let's also modify our `disconnect` reducer to remove the circles from the arena when the player disconnects from the database server. + +```rust +#[spacetimedb::reducer(client_disconnected)] +pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { + let player = ctx + .db + .player() + .identity() + .find(&ctx.sender) + .ok_or("Player not found")?; + let player_id = player.player_id; + ctx.db.logged_out_player().insert(player); + ctx.db.player().identity().delete(&ctx.sender); + + // Remove any circles from the arena + for circle in ctx.db.circle().player_id().filter(&player_id) { + ctx.db.entity().entity_id().delete(&circle.entity_id); + ctx.db.circle().entity_id().delete(&circle.entity_id); + } + + Ok(()) +} +``` Add the following to the bottom of your file. @@ -820,60 +863,9 @@ SPACETIMEDB_REDUCER(enter_game, ReducerContext ctx, std::string name) { ``` The `enter_game` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. - - Let's also modify our `disconnect` reducer to remove the circles from the arena when the player disconnects from the database server. - - - -```csharp -[Reducer(ReducerKind.ClientDisconnected)] -public static void Disconnect(ReducerContext ctx) -{ - var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); - // Remove any circles from the arena - foreach (var circle in ctx.Db.circle.player_id.Filter(player.player_id)) - { - var entity = ctx.Db.entity.entity_id.Find(circle.entity_id) ?? throw new Exception("Could not find circle"); - ctx.Db.entity.entity_id.Delete(entity.entity_id); - ctx.Db.circle.entity_id.Delete(entity.entity_id); - } - ctx.Db.logged_out_player.Insert(player); - ctx.Db.player.identity.Delete(player.identity); -} -``` - - - - -```rust -#[spacetimedb::reducer(client_disconnected)] -pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { - let player = ctx - .db - .player() - .identity() - .find(&ctx.sender) - .ok_or("Player not found")?; - let player_id = player.player_id; - ctx.db.logged_out_player().insert(player); - ctx.db.player().identity().delete(&ctx.sender); - - // Remove any circles from the arena - for circle in ctx.db.circle().player_id().filter(&player_id) { - ctx.db.entity().entity_id().delete(&circle.entity_id); - ctx.db.circle().entity_id().delete(&circle.entity_id); - } - - Ok(()) -} -``` - - - - ```cpp SPACETIMEDB_CLIENT_DISCONNECTED(disconnect, ReducerContext ctx) { // Find the player in the player table @@ -900,7 +892,6 @@ SPACETIMEDB_CLIENT_DISCONNECTED(disconnect, ReducerContext ctx) { return Ok(); } ``` - @@ -918,42 +909,42 @@ Now that we've set up our server logic to spawn food and players, let's continue Start by adding `SetupArena` and `CreateBorderCube` methods to your `GameManager` class: -```cs - private void SetupArena(float worldSize) - { - CreateBorderCube(new Vector2(worldSize / 2.0f, worldSize + borderThickness / 2), - new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //North - CreateBorderCube(new Vector2(worldSize / 2.0f, -borderThickness / 2), - new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //South - CreateBorderCube(new Vector2(worldSize + borderThickness / 2, worldSize / 2.0f), - new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //East - CreateBorderCube(new Vector2(-borderThickness / 2, worldSize / 2.0f), - new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //West - } +```csharp +private void SetupArena(float worldSize) +{ + CreateBorderCube(new Vector2(worldSize / 2.0f, worldSize + borderThickness / 2), + new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //North + CreateBorderCube(new Vector2(worldSize / 2.0f, -borderThickness / 2), + new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //South + CreateBorderCube(new Vector2(worldSize + borderThickness / 2, worldSize / 2.0f), + new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //East + CreateBorderCube(new Vector2(-borderThickness / 2, worldSize / 2.0f), + new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //West +} - private void CreateBorderCube(Vector2 position, Vector2 scale) - { - var cube = GameObject.CreatePrimitive(PrimitiveType.Cube); - cube.name = "Border"; - cube.transform.localScale = new Vector3(scale.x, scale.y, 1); - cube.transform.position = new Vector3(position.x, position.y, 1); - cube.GetComponent().material = borderMaterial; - } +private void CreateBorderCube(Vector2 position, Vector2 scale) +{ + var cube = GameObject.CreatePrimitive(PrimitiveType.Cube); + cube.name = "Border"; + cube.transform.localScale = new Vector3(scale.x, scale.y, 1); + cube.transform.position = new Vector3(position.x, position.y, 1); + cube.GetComponent().material = borderMaterial; +} ``` In your `HandleSubscriptionApplied` let's now call `SetupArena` method. Modify your `HandleSubscriptionApplied` method as in the below. -```cs - private void HandleSubscriptionApplied(SubscriptionEventContext ctx) - { - Debug.Log("Subscription applied!"); - OnSubscriptionApplied?.Invoke(); +```csharp +private void HandleSubscriptionApplied(SubscriptionEventContext ctx) +{ + Debug.Log("Subscription applied!"); + OnSubscriptionApplied?.Invoke(); - // Once we have the initial subscription sync'd to the client cache - // Get the world size from the config table and set up the arena - var worldSize = Conn.Db.Config.Id.Find(0).WorldSize; - SetupArena(worldSize); - } + // Once we have the initial subscription sync'd to the client cache + // Get the world size from the config table and set up the arena + var worldSize = Conn.Db.Config.Id.Find(0).WorldSize; + SetupArena(worldSize); +} ``` The `OnApplied` callback will be called after the server synchronizes the initial state of your tables with your client. Once the sync has happened, we can look up the world size from the `config` table and use it to set up our arena. @@ -979,10 +970,10 @@ Next repeat that same process for the `FoodPrefab` and `Food Controller` compone In the `Project` view, double click the `CirclePrefab` to bring it up in the scene view. Right-click anywhere in the hierarchy and navigate to: ``` -UI > Text - Text Mesh Pro +3d Object > Text - Text Mesh Pro ``` -This will add a label to the circle prefab. You may need to import "TextMeshPro Essential Resources" into Unity in order to add the TextMeshPro element. Your logs will say "[TMP Essential Resources] have been imported." if it has worked correctly. Don't forget to set the transform position of the label to `Pos X: 0, Pos Y: 0, Pos Z: 0`. +This will add a label to the circle prefab. You may need to import "TextMeshPro Essential Resources" into Unity in order to add the TextMeshPro element. Your logs will say "[TMP Essential Resources] have been imported." if it has worked correctly. Don't forget to set the transform position of the label to `Pos X: 0, Pos Y: 0, Pos Z: 0`. You should also change the font size to 5 or something close to that according to your preference and set the alignment to `Center` and `Middle`. Finally we need to make the `PlayerPrefab`. In the hierarchy window, create a new `GameObject` by right-clicking and selecting: @@ -998,7 +989,7 @@ Let's also create an `EntityController` script which will serve as a base class Create a new file called `EntityController.cs` and replace its contents with: -```cs +```csharp using SpacetimeDB.Types; using System; using System.Collections; @@ -1075,8 +1066,7 @@ The `EntityController` script just provides some helper functions and basic func At this point you'll have a compilation error because we can't yet convert from `SpacetimeDB.Types.DbVector2` to `UnityEngine.Vector2`. To fix this, let's also create a new `Extensions.cs` script and replace the contents with: -```cs -using SpacetimeDB.Types; +```csharp using UnityEngine; namespace SpacetimeDB.Types @@ -1102,7 +1092,7 @@ This just allows us to implicitly convert between our `DbVector2` type and the U Now open the `CircleController` script and modify the contents of the `CircleController` script to be: -```cs +```csharp using System; using System.Collections.Generic; using SpacetimeDB; @@ -1111,36 +1101,35 @@ using UnityEngine; public class CircleController : EntityController { - public static Color[] ColorPalette = new[] - { + private static Color[] ColorPalette = { //Yellow - (Color)new Color32(175, 159, 49, 255), - (Color)new Color32(175, 116, 49, 255), + new Color32(175, 159, 49, 255), + new Color32(175, 116, 49, 255), //Purple - (Color)new Color32(112, 47, 252, 255), - (Color)new Color32(51, 91, 252, 255), + new Color32(112, 47, 252, 255), + new Color32(51, 91, 252, 255), //Red - (Color)new Color32(176, 54, 54, 255), - (Color)new Color32(176, 109, 54, 255), - (Color)new Color32(141, 43, 99, 255), + new Color32(176, 54, 54, 255), + new Color32(176, 109, 54, 255), + new Color32(141, 43, 99, 255), //Blue - (Color)new Color32(2, 188, 250, 255), - (Color)new Color32(7, 50, 251, 255), - (Color)new Color32(2, 28, 146, 255), + new Color32(2, 188, 250, 255), + new Color32(7, 50, 251, 255), + new Color32(2, 28, 146, 255), }; - private PlayerController Owner; + private PlayerController Owner { get; set; } public void Spawn(Circle circle, PlayerController owner) { base.Spawn(circle.EntityId); SetColor(ColorPalette[circle.PlayerId % ColorPalette.Length]); - this.Owner = owner; - GetComponentInChildren().text = owner.Username; + Owner = owner; + GetComponentInChildren().text = owner.Username; } public override void OnDelete(EventContext context) @@ -1159,22 +1148,21 @@ Note that the `CircleController` inherits from the `EntityController`, not `Mono Next open the `FoodController.cs` file and replace the contents with: -```cs +```csharp using SpacetimeDB.Types; -using Unity.VisualScripting; using UnityEngine; public class FoodController : EntityController { - public static Color[] ColorPalette = new[] + private static readonly Color[] ColorPalette = { - (Color)new Color32(119, 252, 173, 255), - (Color)new Color32(76, 250, 146, 255), - (Color)new Color32(35, 246, 120, 255), + new Color32(119, 252, 173, 255), + new Color32(76, 250, 146, 255), + new Color32(35, 246, 120, 255), - (Color)new Color32(119, 251, 201, 255), - (Color)new Color32(76, 249, 184, 255), - (Color)new Color32(35, 245, 165, 255), + new Color32(119, 251, 201, 255), + new Color32(76, 249, 184, 255), + new Color32(35, 245, 165, 255), }; public void Spawn(Food food) @@ -1189,7 +1177,7 @@ public class FoodController : EntityController Open the `PlayerController` script and modify the contents of the `PlayerController` script to be: -```cs +```csharp using System.Collections.Generic; using System.Linq; using SpacetimeDB; @@ -1296,7 +1284,7 @@ public class PlayerController : MonoBehaviour Let's also add a new `PrefabManager.cs` script which we can use as a factory for creating prefabs. Replace the contents of the file with: -```cs +```csharp using SpacetimeDB.Types; using System.Collections; using System.Collections.Generic; @@ -1350,103 +1338,102 @@ We've now prepared our Unity project so that we can hook up the data from our ta Add a couple dictionaries at the top of your `GameManager` class which we'll use to hold onto the game objects we create for our scene. Add these two lines just below your `DbConnection` like so: -```cs - public static DbConnection Conn { get; private set; } +```csharp +public static DbConnection Conn { get; private set; } - public static Dictionary Entities = new Dictionary(); - public static Dictionary Players = new Dictionary(); +public static Dictionary Entities = new Dictionary(); +public static Dictionary Players = new Dictionary(); ``` Next lets add some callbacks when rows change in the database. Modify the `HandleConnect` method as below. -```cs - // Called when we connect to SpacetimeDB and receive our client identity - void HandleConnect(DbConnection conn, Identity identity, string token) - { - Debug.Log("Connected."); - AuthToken.SaveToken(token); - LocalIdentity = identity; - - conn.Db.Circle.OnInsert += CircleOnInsert; - conn.Db.Entity.OnUpdate += EntityOnUpdate; - conn.Db.Entity.OnDelete += EntityOnDelete; - conn.Db.Food.OnInsert += FoodOnInsert; - conn.Db.Player.OnInsert += PlayerOnInsert; - conn.Db.Player.OnDelete += PlayerOnDelete; - - OnConnected?.Invoke(); - - // Request all tables - Conn.SubscriptionBuilder() - .OnApplied(HandleSubscriptionApplied) - .SubscribeToAllTables(); - } +```csharp +void HandleConnect(DbConnection conn, Identity identity, string token) +{ + Debug.Log("Connected."); + AuthToken.SaveToken(token); + LocalIdentity = identity; + + conn.Db.Circle.OnInsert += CircleOnInsert; + conn.Db.Entity.OnUpdate += EntityOnUpdate; + conn.Db.Entity.OnDelete += EntityOnDelete; + conn.Db.Food.OnInsert += FoodOnInsert; + conn.Db.Player.OnInsert += PlayerOnInsert; + conn.Db.Player.OnDelete += PlayerOnDelete; + + OnConnected?.Invoke(); + + // Request all tables + Conn.SubscriptionBuilder() + .OnApplied(HandleSubscriptionApplied) + .SubscribeToAllTables(); +} ``` Next add the following implementations for those callbacks to the `GameManager` class. -```cs - private static void CircleOnInsert(EventContext context, Circle insertedValue) - { - var player = GetOrCreatePlayer(insertedValue.PlayerId); - var entityController = PrefabManager.SpawnCircle(insertedValue, player); - Entities.Add(insertedValue.EntityId, entityController); - } +```csharp +private static void CircleOnInsert(EventContext context, Circle insertedValue) +{ + var player = GetOrCreatePlayer(insertedValue.PlayerId); + var entityController = PrefabManager.SpawnCircle(insertedValue, player); + Entities.Add(insertedValue.EntityId, entityController); +} - private static void EntityOnUpdate(EventContext context, Entity oldEntity, Entity newEntity) +private static void EntityOnUpdate(EventContext context, Entity oldEntity, Entity newEntity) +{ + if (!Entities.TryGetValue(newEntity.EntityId, out var entityController)) { - if (!Entities.TryGetValue(newEntity.EntityId, out var entityController)) - { - return; - } - entityController.OnEntityUpdated(newEntity); + return; } + entityController.OnEntityUpdated(newEntity); +} - private static void EntityOnDelete(EventContext context, Entity oldEntity) +private static void EntityOnDelete(EventContext context, Entity oldEntity) +{ + if (Entities.Remove(oldEntity.EntityId, out var entityController)) { - if (Entities.Remove(oldEntity.EntityId, out var entityController)) - { - entityController.OnDelete(context); - } + entityController.OnDelete(context); } +} - private static void FoodOnInsert(EventContext context, Food insertedValue) - { - var entityController = PrefabManager.SpawnFood(insertedValue); - Entities.Add(insertedValue.EntityId, entityController); - } +private static void FoodOnInsert(EventContext context, Food insertedValue) +{ + var entityController = PrefabManager.SpawnFood(insertedValue); + Entities.Add(insertedValue.EntityId, entityController); +} - private static void PlayerOnInsert(EventContext context, Player insertedPlayer) - { - GetOrCreatePlayer(insertedPlayer.PlayerId); - } +private static void PlayerOnInsert(EventContext context, Player insertedPlayer) +{ + GetOrCreatePlayer(insertedPlayer.PlayerId); +} - private static void PlayerOnDelete(EventContext context, Player deletedvalue) +private static void PlayerOnDelete(EventContext context, Player deletedvalue) +{ + if (Players.Remove(deletedvalue.PlayerId, out var playerController)) { - if (Players.Remove(deletedvalue.PlayerId, out var playerController)) - { - GameObject.Destroy(playerController.gameObject); - } + GameObject.Destroy(playerController.gameObject); } +} - private static PlayerController GetOrCreatePlayer(int playerId) +private static PlayerController GetOrCreatePlayer(int playerId) +{ + if (!Players.TryGetValue(playerId, out var playerController)) { - if (!Players.TryGetValue(playerId, out var playerController)) - { - var player = Conn.Db.Player.PlayerId.Find(playerId); - playerController = PrefabManager.SpawnPlayer(player); - Players.Add(playerId, playerController); - } - - return playerController; + var player = Conn.Db.Player.PlayerId.Find(playerId); + playerController = PrefabManager.SpawnPlayer(player); + Players.Add(playerId, playerController); } + + return playerController; +} ``` ### Camera Controller One of the last steps is to create a camera controller to make sure the camera moves around with the player. Create a script called `CameraController.cs` and add it to your project. Replace the contents of the file with this: -```cs +```csharp using System.Collections; using System.Collections.Generic; using UnityEngine; @@ -1498,47 +1485,47 @@ Add the `CameraController` as a component to the `Main Camera` object in the sce Lastly modify the `GameManager.SetupArena` method to set the `WorldSize` on the `CameraController`. -```cs - private void SetupArena(float worldSize) - { - CreateBorderCube(new Vector2(worldSize / 2.0f, worldSize + borderThickness / 2), - new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //North - CreateBorderCube(new Vector2(worldSize / 2.0f, -borderThickness / 2), - new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //South - CreateBorderCube(new Vector2(worldSize + borderThickness / 2, worldSize / 2.0f), - new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //East - CreateBorderCube(new Vector2(-borderThickness / 2, worldSize / 2.0f), - new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //West - - // Set the world size for the camera controller - CameraController.WorldSize = worldSize; - } +```csharp +private void SetupArena(float worldSize) +{ + CreateBorderCube(new Vector2(worldSize / 2.0f, worldSize + borderThickness / 2), + new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //North + CreateBorderCube(new Vector2(worldSize / 2.0f, -borderThickness / 2), + new Vector2(worldSize + borderThickness * 2.0f, borderThickness)); //South + CreateBorderCube(new Vector2(worldSize + borderThickness / 2, worldSize / 2.0f), + new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //East + CreateBorderCube(new Vector2(-borderThickness / 2, worldSize / 2.0f), + new Vector2(borderThickness, worldSize + borderThickness * 2.0f)); //West + + // Set the world size for the camera controller + CameraController.WorldSize = worldSize; +} ``` ### Entering the Game -At this point, you may need to regenerate your bindings the following command from the `blackholio/spacetimedb` directory. +At this point, you may need to regenerate your bindings the following command from the `blackholio-server/spacetimedb` directory. ```sh -spacetime generate --lang csharp --out-dir ../Assets/module_bindings +spacetime generate --lang csharp --out-dir ../../Assets/module_bindings ``` The last step is to call the `enter_game` reducer on the server, passing in a username for our player, which will spawn a circle for our player. For the sake of simplicity, let's call the `enter_game` reducer from the `HandleSubscriptionApplied` callback with the name "3Blave". -```cs - private void HandleSubscriptionApplied(SubscriptionEventContext ctx) - { - Debug.Log("Subscription applied!"); - OnSubscriptionApplied?.Invoke(); +```csharp +private void HandleSubscriptionApplied(SubscriptionEventContext ctx) +{ + Debug.Log("Subscription applied!"); + OnSubscriptionApplied?.Invoke(); - // Once we have the initial subscription sync'd to the client cache - // Get the world size from the config table and set up the arena - var worldSize = Conn.Db.Config.Id.Find(0).WorldSize; - SetupArena(worldSize); + // Once we have the initial subscription sync'd to the client cache + // Get the world size from the config table and set up the arena + var worldSize = Conn.Db.Config.Id.Find(0).WorldSize; + SetupArena(worldSize); - // Call enter game with the player name 3Blave - ctx.Reducers.EnterGame("3Blave"); - } + // Call enter game with the player name 3Blave + ctx.Reducers.EnterGame("3Blave"); +} ``` ### Trying it out @@ -1547,12 +1534,6 @@ At this point, after publishing our module we can press the play button to see t ![Player on screen](/images/unity/part-3-player-on-screen.png) -:::note - -The label won't be centered at this point. Feel free to adjust it if you like. We just didn't want to complicate the tutorial. - -::: - ### Troubleshooting - If you get an error when running the generate command, make sure you have an empty subfolder in your Unity project Assets folder called `module_bindings` 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 5ca13a1b85c..df5284ca12e 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 @@ -18,7 +18,7 @@ At this point, we're very close to having a working game. All we have to do is m -Let's start by building out a simple math library to help us do collision calculations. Create a new `Math.cs` file in the `csharp-server` directory and add the following contents. Let's also remove the `DbVector2` type from `Lib.cs`. +Let's start by building out a simple math library to help us do collision calculations. Create a new `Math.cs` file in the `blackholio-server/spacetimedb` directory and add the following contents. Let's also remove the `DbVector2` type from `Lib.cs`. ```csharp [SpacetimeDB.Type] @@ -61,7 +61,7 @@ public static void UpdatePlayerInput(ReducerContext ctx, DbVector2 direction) } ``` -This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.Sender` value is not set by the client. Instead `ctx.Sender` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. +This is a simple reducer that takes the movement input from the client and applies it to all circles that the player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.Sender` value is not set by the client. Instead `ctx.Sender` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. @@ -219,7 +219,7 @@ pub fn update_player_input(ctx: &ReducerContext, direction: DbVector2) -> Result } ``` -This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.sender()` value is not set by the client. Instead `ctx.sender()` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. +This is a simple reducer that takes the movement input from the client and applies it to all circles that the player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.sender()` value is not set by the client. Instead `ctx.sender()` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. @@ -324,7 +324,7 @@ SPACETIMEDB_REDUCER(update_player_input, ReducerContext ctx, DbVector2 direction } ``` -This is a simple reducer that takes the movement input from the client and applies them to all circles that that player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.sender()` value is not set by the client. Instead `ctx.sender()` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. +This is a simple reducer that takes the movement input from the client and applies it to all circles that the player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.sender()` value is not set by the client. Instead `ctx.sender()` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. @@ -334,7 +334,6 @@ Finally, let's schedule a reducer to run every 50 milliseconds to move the playe ```csharp -[Table(Accessor = "move_all_players_timer", Scheduled = nameof(MoveAllPlayers), ScheduledAt = nameof(scheduled_at))] public partial struct MoveAllPlayersTimer { [PrimaryKey, AutoInc] @@ -349,26 +348,27 @@ public static float MassToMaxMoveSpeed(int mass) => 2f * START_PLAYER_SPEED / (1 [Reducer] public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) { - var world_size = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + var worldSize = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; - var circle_directions = ctx.Db.circle.Iter().Select(c => (c.entity_id, c.direction * c.speed)).ToDictionary(); + // var circleDirections = ctx.Db.circle.Iter().Select(c => (c.entity_id, c.direction * c.speed)).ToDictionary(); // Handle player input foreach (var circle in ctx.Db.circle.Iter()) { - var check_entity = ctx.Db.entity.entity_id.Find(circle.entity_id); - if (check_entity == null) + var checkEntity = ctx.Db.entity.entity_id.Find(circle.entity_id); + if (!checkEntity.HasValue) { // This can happen if the circle has been eaten by another circle. continue; } - var circle_entity = check_entity.Value; - var circle_radius = MassToRadius(circle_entity.mass); - var direction = circle_directions[circle.entity_id]; - var new_pos = circle_entity.position + direction * MassToMaxMoveSpeed(circle_entity.mass); - circle_entity.position.x = Math.Clamp(new_pos.x, circle_radius, world_size - circle_radius); - circle_entity.position.y = Math.Clamp(new_pos.y, circle_radius, world_size - circle_radius); - ctx.Db.entity.entity_id.Update(circle_entity); + + var circleEntity = checkEntity.Value; + var circleRadius = MassToRadius(circleEntity.mass); + var direction = circle.direction * circle.speed; + var newPosition = circleEntity.position + direction * MassToMaxMoveSpeed(circleEntity.mass); + circleEntity.position.x = Math.Clamp(newPosition.x, circleRadius, worldSize - circleRadius); + circleEntity.position.y = Math.Clamp(newPosition.y, circleRadius, worldSize - circleRadius); + ctx.Db.entity.entity_id.Update(circleEntity); } } ``` @@ -527,14 +527,14 @@ spacetime publish --server local blackholio --delete-data Regenerate your server bindings with: ```sh -spacetime generate --lang csharp --out-dir ../Assets/module_bindings +spacetime generate --lang csharp --out-dir ../../module_bindings ``` ### Moving on the Client All that's left is to modify our `PlayerController` on the client to call the `update_player_input` reducer. Open `PlayerController.cs` and add an `Update` function: -```cs +```csharp public void Update() { if (!IsLocalPlayer || NumberOfOwnedCircles == 0) @@ -576,98 +576,104 @@ public void Update() Let's try it out! Press play and roam freely around the arena! Now we're cooking with gas. +:::note + +You may get errors complaining that you're using the wrong Input System. If that's the case, open the Unity Project Settings via `Edit -> Project Settings...`, select the `Player` tab on the left, scroll down to `Other Settings -> Configuration`, find `Active Input Handling` and select `Input Manager (Old)` (or `Both`). The Unity Editor will have to restart to apply the change. + +::: + +### Collisions and Eating Food + +Let's try it out! Press play and roam freely around the arena! Now we're cooking with gas. + ### Collisions and Eating Food Well this is pretty fun, but wouldn't it be better if we could eat food and grow our circle? Surely, that's going to be a pain, right? -Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `IsOverlapping` helper function which does some basic math based on mass radii, and modify our `MoveAllPlayers` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze for SpacetimeDB. +Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `IsOverlapping` helper function which does some basic math based on mass radii, and modify our `MoveAllPlayers` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze. Sometimes, simple is best! -Sometimes simple is best! Add the following code to the `Module` class of your `Lib.cs` file and make sure to replace the existing `MoveAllPlayers` reducer. +Add the following code to the `Module` class of your `Lib.cs` file and make sure to replace the existing `MoveAllPlayers` reducer. ```csharp -const float MINIMUM_SAFE_MASS_RATIO = 0.85f; +private const float MINIMUM_SAFE_MASS_RATIO = 0.85f; public static bool IsOverlapping(Entity a, Entity b) { var dx = a.position.x - b.position.x; var dy = a.position.y - b.position.y; - var distance_sq = dx * dx + dy * dy; + var distanceSq = dx * dx + dy * dy; - var radius_a = MassToRadius(a.mass); - var radius_b = MassToRadius(b.mass); + var aRadius = MassToRadius(a.mass); + var bRadius = MassToRadius(b.mass); // If the distance between the two circle centers is less than the // maximum radius, then the center of the smaller circle is inside // the larger circle. This gives some leeway for the circles to overlap // before being eaten. - var max_radius = radius_a > radius_b ? radius_a: radius_b; - return distance_sq <= max_radius * max_radius; + var maxRadius = aRadius > bRadius ? aRadius: bRadius; + return distanceSq <= maxRadius * maxRadius; } [Reducer] public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) { - var world_size = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + var worldSize = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; // Handle player input foreach (var circle in ctx.Db.circle.Iter()) { - var check_entity = ctx.Db.entity.entity_id.Find(circle.entity_id); - if (check_entity == null) + var checkEntity = ctx.Db.entity.entity_id.Find(circle.entity_id); + if (checkEntity == null) { // This can happen if the circle has been eaten by another circle. continue; } - var circle_entity = check_entity.Value; - var circle_radius = MassToRadius(circle_entity.mass); + var circleEntity = checkEntity.Value; + var circleRadius = MassToRadius(circleEntity.mass); var direction = circle.direction * circle.speed; - var new_pos = circle_entity.position + direction * MassToMaxMoveSpeed(circle_entity.mass); - circle_entity.position.x = Math.Clamp(new_pos.x, circle_radius, world_size - circle_radius); - circle_entity.position.y = Math.Clamp(new_pos.y, circle_radius, world_size - circle_radius); + var newPosition = circleEntity.position + direction * MassToMaxMoveSpeed(circleEntity.mass); + circleEntity.position.x = Math.Clamp(newPosition.x, circleRadius, worldSize - circleRadius); + circleEntity.position.y = Math.Clamp(newPosition.y, circleRadius, worldSize - circleRadius); // Check collisions foreach (var entity in ctx.Db.entity.Iter()) { - if (entity.entity_id == circle_entity.entity_id) - { + if (entity.entity_id == circleEntity.entity_id || !IsOverlapping(circleEntity, entity)) continue; + + // Check to see if we're overlapping with food + if (ctx.Db.food.entity_id.Find(entity.entity_id).HasValue) { + ctx.Db.entity.entity_id.Delete(entity.entity_id); + ctx.Db.food.entity_id.Delete(entity.entity_id); + circleEntity.mass += entity.mass; + continue; } - if (IsOverlapping(circle_entity, entity)) - { - // Check to see if we're overlapping with food - if (ctx.Db.food.entity_id.Find(entity.entity_id).HasValue) { - ctx.Db.entity.entity_id.Delete(entity.entity_id); - ctx.Db.food.entity_id.Delete(entity.entity_id); - circle_entity.mass += entity.mass; - } - // Check to see if we're overlapping with another circle owned by another player - var other_circle = ctx.Db.circle.entity_id.Find(entity.entity_id); - if (other_circle.HasValue && - other_circle.Value.player_id != circle.player_id) + // Check to see if we're overlapping with another circle owned by another player + var otherCircle = ctx.Db.circle.entity_id.Find(entity.entity_id); + if (otherCircle.HasValue && otherCircle.Value.player_id != circle.player_id) + { + var massRatio = (float)entity.mass / circleEntity.mass; + if (massRatio < MINIMUM_SAFE_MASS_RATIO) { - var mass_ratio = (float)entity.mass / circle_entity.mass; - if (mass_ratio < MINIMUM_SAFE_MASS_RATIO) - { - ctx.Db.entity.entity_id.Delete(entity.entity_id); - ctx.Db.circle.entity_id.Delete(entity.entity_id); - circle_entity.mass += entity.mass; - } + ctx.Db.entity.entity_id.Delete(entity.entity_id); + ctx.Db.circle.entity_id.Delete(entity.entity_id); + circleEntity.mass += entity.mass; } } } - ctx.Db.entity.entity_id.Update(circle_entity); + ctx.Db.entity.entity_id.Update(circleEntity); } } ``` - -Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `is_overlapping` helper function which does some basic math based on mass radii, and modify our `move_all_player` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze for SpacetimeDB. + +Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `is_overlapping` helper function which does some basic math based on mass radii, and modify our `move_all_players` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze. Sometimes, simple is best! -Sometimes simple is best! Add the following code to your `lib.rs` file and make sure to replace the existing `move_all_players` reducer. +Add the following code to your `lib.rs` file and make sure to replace the existing `move_all_players` reducer. ```rust const MINIMUM_SAFE_MASS_RATIO: f32 = 0.85; @@ -751,9 +757,9 @@ pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Re -Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `is_overlapping` helper function which does some basic math based on mass radii, and modify our `move_all_players` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze for SpacetimeDB. +Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `is_overlapping` helper function which does some basic math based on mass radii, and modify our `move_all_players` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze. Sometimes, simple is best! -Sometimes simple is best! Add the following code to your `lib.cpp` file and make sure to replace the existing `move_all_players` reducer. +Add the following code to your `lib.cpp` file and make sure to replace the existing `move_all_players` reducer. ```cpp const float MINIMUM_SAFE_MASS_RATIO = 0.85f; @@ -850,12 +856,12 @@ Just update your module by publishing and you're on your way eating food! Try to We didn't even have to update the client, because our client's `OnDelete` callbacks already handled deleting entities from the scene when they're deleted on the server. SpacetimeDB just synchronizes the state with your client automatically. -Notice that the food automatically respawns as you vaccuum them up. This is because our scheduled reducer is automatically replacing the food 2 times per second, to ensure that there is always 600 food on the map. +Notice that the food automatically respawns as you vaccuum them up. This is because our scheduled reducer is automatically replacing the food 2 times per second, to ensure that there is always close to 600 food on the map. ## Connecting to Maincloud - Publish to Maincloud `spacetime publish --server maincloud --delete-data` - - `` This name should be unique and cannot contain any special characters other than internal hyphens (`-`). + - `` This name should be unique and cannot contain any special characters other than internal hyphens (`-`). You will have to update the database name in `blackholio-server/spacetime.local.json` to match. - Update the URL in the Unity project to: `https://maincloud.spacetimedb.com` - Update the module name in the Unity project to ``. - Clear the PlayerPrefs in Start() within `GameManager.cs` @@ -890,7 +896,7 @@ To delete your Maincloud database, you can run: `spacetime delete --server mainc used scheduled reducers to implement a physics simulation right within your module. - + So far you've learned how to configure a new Unity project to work with SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. Within the module, you've learned how to create tables, update tables, and @@ -899,9 +905,9 @@ To delete your Maincloud database, you can run: `spacetime delete --server mainc learned how we can used scheduled reducers to implement a physics simulation right within your module. - -So far you've learned how to configure a new Unity project to work with SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. Within the module, you've learned how to create tables, update tables, and write reducers. You've learned about special reducers like `SPACETIMEDB_CLIENT_CONNECTED` and `SPACETIMEDB_INIT` and how to create scheduled reducers. You learned how we can use scheduled reducers to implement a physics simulation right within your module. - + + So far you've learned how to configure a new Unity project to work with SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. Within the module, you've learned how to create tables, update tables, and write reducers. You've learned about special reducers like `SPACETIMEDB_CLIENT_CONNECTED` and `SPACETIMEDB_INIT` and how to create scheduled reducers. You learned how we can use scheduled reducers to implement a physics simulation right within your module. + You've also learned how view module logs and connect your client to your database server, call reducers from the client and synchronize the data with client. Finally you learned how to use that synchronized data to draw game objects on the screen, so that we can interact with them and play a game! @@ -910,13 +916,14 @@ And all of that completely from scratch! Our game is still pretty limited in some important ways. The biggest limitation is that the client assumes your username is "3Blave" and doesn't give you a menu or a window to set your username before joining the game. Notably, we do not have a unique constraint on the `name` column, so that does not prevent us from connecting multiple clients to the same server. -In fact, if you build what we have and run multiple clients you already have a (very simple) MMO! You can connect hundreds of players to this arena with SpacetimeDB. +In fact, if you build what we have and run multiple clients you already have a (very simple) MMO! You can connect hundreds of players to this arena with SpacetimeDB. Note that you would need to run the client in different computers to use different auth tokens. There's still plenty more we can do to build this into a proper game though. For example, you might want to also add - Username chooser - Chat - Leaderboards +- The ability to split into multiple circles - Nice animations - Nice shaders - Space theme! diff --git a/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00200-part-1.md b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00200-part-1.md new file mode 100644 index 00000000000..81ed0bd5402 --- /dev/null +++ b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00200-part-1.md @@ -0,0 +1,84 @@ +--- +title: 1 - Setup +slug: /tutorials/godot/part-1 +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + + +![Unity Tutorial Hero Image](/images/unity/part-1-hero-image.png) + +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! + +> A completed version of the game we'll create in this tutorial is available at: +> +> [https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio) + +## Setting up the Tutorial Godot Project + +In this section, we will guide you through the process of setting up a Godot Project that will serve as the starting point for our tutorial. By the end of this section, you will have a basic Godot project and be ready to implement the server functionality. + +### Step 1: Create a Blank Godot Project + +SpacetimeDB supports Godot version `4.6.2` or later. See [the overview](.) for more information on specific supported versions. + +Open Godot and create a new project by selecting "+ Create" from the Project Manager. + +// TODO: THIS IMAGE MUST BE UPDATED +![Godot Project Manager](/images/godot/part-1-godot-project-manager.jpg) + +For `Project Name` use `blackholio`. For `Project Location` select a directory that you can navigate to via the CLI because we will need to do so in part 2. + +![Create Project](/images/godot/part-1-create-project.png) + +Any Renderer will work. + +Click "Create" to generate the blank project. + +### Import the SpacetimeDB Godot SDK + +We will add the SpacetimeDB SDK using NuGet. Godot does not initialize a C# Project when creating a new Godot project, so we need to create a C# Script first to initialize our Godot C# Project. + +1. **Right-click** in the **FileSystem** dock, then select `New Script`. +2. Select `C#` in **Lanugage** and name it `GameManager`. We will use this script later to put the high level initialization and coordination logic for our game. + +![Create GameManager Script](/images/godot/part-1-game-manager-script.png) + +3. Add the `SpacetimeDB.ClientSDK` [NuGet package](https://www.nuget.org/packages/SpacetimeDB.ClientSDK/) using Visual Studio or Rider _NuGet Package Manager_ or via the .NET CLI: + +```bash +dotnet add package SpacetimeDB.ClientSDK +``` + +The SpacetimeDB Godot SDK provides helpful tools for integrating SpacetimeDB into Godot, including a connection update manager which will synchronize your Godot client's state with your SpacetimeDB database in accordance with your subscription queries. + +### Create a new Scene + +1. **Create a 2D Scene**: + - In the **Scene** Dock (usually on the top left), click on **Create Root Node: -> 2D Scene**. + - Alternatively, you can click the **+** button to add a child node and select a **Node 2D**. + +![Create 2D Scene](/images/godot/part-1-godot-create-2d-scene.jpg) + +2. **Rename the GameObject**: + - In the **Scene** dock, double-click on the newly created node (or `right-click -> rename`) and rename it to `Main`. + +3. **Attach the GameManager Script**: + - Drag and drop the `GameManager` script from the **Scene** dock onto the `Main` Node in the **Scene** window. + +You will see a warning saying `This inspector might be out of date. Please build the C# project.`. This is because Godot doesn't automatically compile our C# project and we need to run the game for the compilation to take place. Let's do that. + +4. **Save current Scene**: + - Press `Ctrl + S` (or `Cmd + S` on Mac) to save the current scene. Name it `main.tscn`. + - Alternatively, go to `Scene -> Save Scene As`. + +5. **Run Project and build C# Project**: + - Press `F5` (or `Cmd + B` on Mac) to run the project, click on `Select Current` to select the current scene as the main one. + - Alternatively, you can click on the play button in the top menu. + +Our Godot project is all set up! If you press play, it will show a blank screen, but it should start the game without any errors. Now we're ready to get started on our SpacetimeDB server module, so we have something to connect to! + +### Create the Server Module + +We've now got the very basics set up. In [part 2](part-2) you'll learn the basics of how to create a SpacetimeDB server module and how to connect to it from your client. \ No newline at end of file diff --git a/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00300-part-2.md b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00300-part-2.md new file mode 100644 index 00000000000..845219c5e26 --- /dev/null +++ b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00300-part-2.md @@ -0,0 +1,853 @@ +--- +title: 2 - Connecting to SpacetimeDB +slug: /tutorials/godot/part-2 +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import { CppModuleVersionNotice } from "@site/src/components/CppModuleVersionNotice"; + + +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! + +This progressive tutorial is continued from [part 1](./00200-part-1.md). + +## Project Structure + +Now that we have our client project setup we can configure the module directory. Regardless of what language you choose, your module will always go into a `spacetimedb` directory within your client directory like this: + +``` +blackholio/ # This is the directory where your Godot project lives +├── blackholio.csproj +├── module_bindings/ # This directory contains the client logic to communicate with the module +├── ... # rest of the Godot files +└── blackholio-server/ + └── spacetimedb/ # This is where your server module lives +``` + +Your `module_bindings` directory can go wherever you want as long as it is inside of `res://` in your Godot project. We'll configure this in a later step. For now we will create a new module in the `blackholio` directory which will generate the `spacetimedb` directory for us. + + +## Create a Server Module + +If you have not already installed the `spacetime` CLI, check out our [Getting Started](../../00100-getting-started/00100-getting-started.md) guide for instructions on how to install. + +In the same directory that contains your `blackholio` project, run the following command to initialize the SpacetimeDB server module project with your desired language: + +:::warning +The `blackholio` directory specified here is the same `blackholio` directory you created during part 1. +::: + + + +Run the following command to initialize the SpacetimeDB server module project with C# as the language: + +```bash +spacetime init --lang csharp --server-only blackholio +``` + +Use `blackholio-server` for the project path and `blackholio` for the database name. + +This command creates a new folder named `blackholio-server` inside of your Godot project `blackholio` directory. Inside, there's a `spacetimedb` folder that contains the SpacetimeDB server project with C# as the programming language. + + + +Run the following command to initialize the SpacetimeDB server module project with Rust as the language: + +```bash +spacetime init --lang rust --server-only blackholio +``` + +Use `blackholio-server` for the project path and `blackholio` for the database name. + +This command creates a new folder named `blackholio-server` inside of your Godot project `blackholio` directory. Inside, there's a `spacetimedb` folder that contains the SpacetimeDB server project with Rust as the programming language. + + + + + +Run the following command to initialize the SpacetimeDB server module project with C++ as the language: + +```bash +spacetime init --lang cpp --server-only blackholio +``` + +Use `blackholio-server` for the project path and `blackholio` for the database name. + +This command creates a new folder named `blackholio-server` inside of your Godot project `blackholio` directory. Inside, there's a `spacetimedb` folder that contains the SpacetimeDB server project with C++ as the programming language. + + + +### SpacetimeDB Tables + + + +In this section we'll be making some edits to the file `blackholio-server/spacetimedb/Lib.cs`. We recommend you open up this file in an IDE like VSCode or Rider. + +**Important: Open the `blackholio-server/spacetimedb/Lib.cs` file and delete its contents. We will be writing it from scratch here.** + + + +In this section we'll be making some edits to the file `blackholio-server/spacetimedb/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. + +**Important: Open the `blackholio-server/spacetimedb/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** + + + +In this section we'll be making some edits to the file `blackholio-server/spacetimedb/src/lib.cpp`. We recommend you open up this file in an IDE like VSCode or Rider. + +**Important: Open the `blackholio-server/spacetimedb/src/lib.cpp` file and delete its contents. We will be writing it from scratch here.** + + + +First we need to add some imports at the top of the file. Some will remain unused for now. + + + +**Copy and paste into Lib.cs:** + +```csharp +using SpacetimeDB; + +public static partial class Module +{ + +} +``` + + + +**Copy and paste into lib.rs:** + +```rust +use std::time::Duration; +use spacetimedb::{rand::Rng, Identity, SpacetimeType, ReducerContext, ScheduleAt, Table, Timestamp}; +``` + + + +**Copy and paste into lib.cpp:** + +```cpp +#include "spacetimedb.h" + +using namespace SpacetimeDB; +``` + + + + +We are going to start by defining a SpacetimeDB _table_. A _table_ in SpacetimeDB is a relational database table which stores rows, similar to something you might find in SQL. SpacetimeDB tables differ from normal relational database tables in that they are stored fully in memory, are blazing fast to access, and are defined in your module code, rather than in SQL. + + + +Each row in a SpacetimeDB table is associated with a `struct` type in C#. + +Let's start by defining the `Config` table. This is a simple table which will store some metadata about our game's state. Add the following code inside the `Module` class in `Lib.cs`. + +```csharp +// We're using this table as a singleton, so in this table +// there will only be one element where the `id` is 0. +[Table(Accessor = "config", Public = true)] +public partial struct Config +{ + [PrimaryKey] + public int id; + public long world_size; +} +``` + +Let's break down this code. This defines a normal C# `struct` with two fields: `id` and `world_size`. We have added the `[Table(Accessor = "config", Public = true)]` attribute the struct. This attribute signals to SpacetimeDB that it should create a new SpacetimeDB table with the row type defined by the `Config` type's fields. + +> Although we're using `lower_snake_case` for our column names to have consistent column names across languages in this tutorial, you can also use `camelCase` or `PascalCase` if you prefer. See [#2168](https://github.com/clockworklabs/SpacetimeDB/issues/2168) for more information. + +The `Table` attribute takes two parameters, an `Accessor` which is the name of the table and what you will use to query the table in SQL, and a `Public` visibility modifier which ensures that the rows of this table are visible to everyone. + +The `[PrimaryKey]` attribute, specifies that the `id` field should be used as the primary key of the table. + + + +Each row in a SpacetimeDB table is associated with a `struct` type in Rust. + +Let's start by defining the `Config` table. This is a simple table which will store some metadata about our game's state. Add the following code to `lib.rs`. + +```rust +// We're using this table as a singleton, so in this table +// there only be one element where the `id` is 0. +#[spacetimedb::table(accessor = config, public)] +pub struct Config { + #[primary_key] + pub id: i32, + pub world_size: i64, +} +``` + +Let's break down this code. This defines a normal Rust `struct` with two fields: `id` and `world_size`. We have decorated the struct with the `spacetimedb::table` macro. This procedural Rust macro signals to SpacetimeDB that it should create a new SpacetimeDB table with the row type defined by the `Config` type's fields. + +The `spacetimedb::table` macro takes two parameters, an `accessor` which is the name of the table and what you will use to query the table in SQL, and a `public` visibility modifier which ensures that the rows of this table are visible to everyone. + +The `#[primary_key]` attribute, specifies that the `id` field should be used as the primary key of the table. + + + +Each row in a SpacetimeDB table is associated with a `struct` type in C++. + +Let's start by defining the `Config` table. This is a simple table which will store some metadata about our game's state. Add the following code to `lib.cpp`. + +```cpp +// We're using this table as a singleton, so in this table +// there will only be one element where the `id` is 0. +struct Config { + int32_t id; + int64_t world_size; +}; +SPACETIMEDB_STRUCT(Config, id, world_size); +SPACETIMEDB_TABLE(Config, config, Public); +FIELD_PrimaryKey(config, id); +``` + +Let's break down this code. This defines a normal C++ `struct` with two fields: `id` and `world_size`. We use the `SPACETIMEDB_STRUCT` macro to register the struct for serialization, listing all field names. Then `SPACETIMEDB_TABLE` registers it as a table with the name `config` and `Public` visibility. + +The `SPACETIMEDB_TABLE` macro takes three parameters: the struct type, the table name (which you'll use to access the table in code), and a visibility modifier (`Public` or `Private`) which determines whether rows are synced to clients. + +The `FIELD_PrimaryKey` macro specifies that the `id` field should be used as the primary key of the table. + + + +> NOTE: The primary key of a row defines the "identity" of the row. A change to a row which doesn't modify the primary key is considered an update, but if you change the primary key, then you have deleted the old row and inserted a new one. + +Learn more about defining tables, including indexes, constraints, and column types, in our [Tables documentation](../../../00200-core-concepts/00300-tables.md). + +### Creating Entities + + + +Next, we're going to define a new `SpacetimeType` called `DbVector2` which we're going to use to store positions. The difference between a `[SpacetimeDB.Type]` and a `[SpacetimeDB.Table]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. Therefore, `DbVector2` is only a type, and does not define a table. + +**Append to the bottom of Lib.cs:** + +```csharp +// This allows us to store 2D points in tables. +[Type] +public partial struct DbVector2 +{ + public float x; + public float y; + + public DbVector2(float x, float y) + { + this.x = x; + this.y = y; + } +} +``` + +Let's create a few tables to represent entities in our game by adding the following to the end of the `Module` class. + +```csharp +[Table(Accessor = "entity", Public = true)] +public partial struct Entity +{ + [PrimaryKey, AutoInc] + public int entity_id; + public DbVector2 position; + public int mass; +} + +[Table(Accessor = "circle", Public = true)] +public partial struct Circle +{ + [PrimaryKey] + public int entity_id; + [SpacetimeDB.Index.BTree] + public int player_id; + public DbVector2 direction; + public float speed; + public Timestamp last_split_time; +} + +[Table(Accessor = "food", Public = true)] +public partial struct Food +{ + [PrimaryKey] + public int entity_id; +} +``` + + + +Next, we're going to define a new `SpacetimeType` called `DbVector2` which we're going to use to store positions. The difference between a `#[derive(SpacetimeType)]` and a `#[spacetimedb(table)]` is that tables actually store data, whereas the deriving `SpacetimeType` just allows you to create a new column of that type in a SpacetimeDB table. Therefore, `DbVector2` is only a type, and does not define a table. + +**Append to the bottom of lib.rs:** + +```rust +// This allows us to store 2D points in tables. +#[derive(SpacetimeType, Clone, Debug)] +pub struct DbVector2 { + pub x: f32, + pub y: f32, +} +``` + +Let's create a few tables to represent entities in our game. + +```rust +#[spacetimedb::table(accessor = entity, public)] +#[derive(Debug, Clone)] +pub struct Entity { + // The `auto_inc` attribute indicates to SpacetimeDB that + // this value should be determined by SpacetimeDB on insert. + #[auto_inc] + #[primary_key] + pub entity_id: i32, + pub position: DbVector2, + pub mass: i32, +} + +#[spacetimedb::table(accessor = circle, public)] +pub struct Circle { + #[primary_key] + pub entity_id: i32, + #[index(btree)] + pub player_id: i32, + pub direction: DbVector2, + pub speed: f32, + pub last_split_time: Timestamp, +} + +#[spacetimedb::table(accessor = food, public)] +pub struct Food { + #[primary_key] + pub entity_id: i32, +} +``` + + + +Next, we're going to define a new `SpacetimeType` called `DbVector2` which we're going to use to store positions. The difference between a regular struct registered with `SPACETIMEDB_STRUCT` and one registered with `SPACETIMEDB_TABLE` is that tables actually store data, whereas registering a struct alone just allows you to use it as a column type in a SpacetimeDB table. Therefore, `DbVector2` is only a type, and does not define a table. + +**Append to the bottom of lib.cpp:** + +```cpp +// This allows us to store 2D points in tables. +struct DbVector2 { + float x; + float y; +}; +SPACETIMEDB_STRUCT(DbVector2, x, y); +``` + +Let's create a few tables to represent entities in our game. + +```cpp +struct Entity { + // The `FIELD_PrimaryKeyAutoInc` constraint indicates to SpacetimeDB that + // this value should be determined by SpacetimeDB on insert. + int32_t entity_id; + DbVector2 position; + int32_t mass; +}; +SPACETIMEDB_STRUCT(Entity, entity_id, position, mass); +SPACETIMEDB_TABLE(Entity, entity, Public); +FIELD_PrimaryKeyAutoInc(entity, entity_id); + +struct Circle { + int32_t entity_id; + int32_t player_id; + DbVector2 direction; + float speed; + Timestamp last_split_time; +}; +SPACETIMEDB_STRUCT(Circle, entity_id, player_id, direction, speed, last_split_time); +SPACETIMEDB_TABLE(Circle, circle, Public); +FIELD_PrimaryKey(circle, entity_id); +FIELD_Index(circle, player_id); + +struct Food { + int32_t entity_id; +}; +SPACETIMEDB_STRUCT(Food, entity_id); +SPACETIMEDB_TABLE(Food, food, Public); +FIELD_PrimaryKey(food, entity_id); +``` + + + + +The first table we defined is the `entity` table. An entity represents an object in our game world. We have decided, for convenience, that all entities in our game should share some common fields, namely `position` and `mass`. + +We can create different types of entities with additional data by creating new tables with additional fields that have an `entity_id` which references a row in the `entity` table. + +We've created two types of entities in our game world: `Food` and `Circle`. `Food` does not have any additional fields beyond the attributes in the `entity` table, so the `food` table simply represents the set of `entity_id`s that we want to recognize as food. + +The `Circle` table, however, represents an entity that is controlled by a player. We've added a few additional fields to a `Circle` like `player_id` so that we know which player that circle belongs to. + +### Representing Players + +Next, let's create a table to store our player data. + + + + +```csharp +[Table(Accessor = "player", Public = true)] +public partial struct Player +{ + [PrimaryKey] + public Identity identity; + [Unique, AutoInc] + public int player_id; + public string name; +} +``` + +There are a few new concepts we should touch on. First of all, we are using the `[Unique]` attribute on the `player_id` field. This attribute adds a constraint to the table that ensures that only one row in the player table has a particular `player_id`. We are also using the `[AutoInc]` attribute on the `player_id` field, which indicates "this field should get automatically assigned an auto-incremented value". + + + + +```rust +#[spacetimedb::table(accessor = player, public)] +#[derive(Debug, Clone)] +pub struct Player { + #[primary_key] + identity: Identity, + #[unique] + #[auto_inc] + player_id: i32, + name: String, +} +``` + +There's a few new concepts we should touch on. First of all, we are using the `#[unique]` attribute on the `player_id` field. This attribute adds a constraint to the table that ensures that only one row in the player table has a particular `player_id`. + + + + +```cpp +struct Player { + Identity identity; + int32_t player_id; + std::string name; +}; +SPACETIMEDB_STRUCT(Player, identity, player_id, name); +SPACETIMEDB_TABLE(Player, player, Public); +FIELD_PrimaryKey(player, identity); +FIELD_UniqueAutoInc(player, player_id); +``` + +There's a few new concepts we should touch on. First of all, we are using the `FIELD_UniqueAutoInc` macro on the `player_id` field. This macro combines two constraints: it ensures that only one row in the player table has a particular `player_id`, and it indicates that this field should get automatically assigned an auto-incremented value. + + + +We also have an `identity` field which uses the `Identity` type. The `Identity` type is an identifier that SpacetimeDB uses to uniquely assign and authenticate SpacetimeDB users. + +### Writing a Reducer + +Next, we write our very first reducer. A reducer is a module function which can be called by clients. Let's write a simple debug reducer to see how they work. + + + + +Add this function to the `Module` class in `Lib.cs`: + +```csharp +[Reducer] +public static void Debug(ReducerContext ctx) +{ + Log.Info($"This reducer was called by {ctx.Sender}"); +} +``` + + + + +```rust +#[spacetimedb::reducer] +pub fn debug(ctx: &ReducerContext) -> Result<(), String> { + log::debug!("This reducer was called by {}.", ctx.sender()); + Ok(()) +} +``` + + + + +```cpp +SPACETIMEDB_REDUCER(debug, ReducerContext ctx) { + LOG_INFO("This reducer was called by " + ctx.sender().to_string()); + return Ok(); +} +``` + + + + +This reducer doesn't update any tables, it just prints out the `Identity` of the client that called it. + +--- + +**SpacetimeDB Reducers** + +"Reducer" is a term coined by Clockwork Labs that refers to a function which when executed "reduces" a set of inserts and deletes into the database state. The term derives from functional programming and is closely related to [similarly named concepts](https://redux.js.org/tutorials/fundamentals/part-2-concepts-data-flow#reducers) in other frameworks like React Redux. Reducers can be called remotely using the CLI, client SDK or can be scheduled to be called at some future time from another reducer call. + +All reducers execute _transactionally_ and _atomically_, meaning that from within the reducer it will appear as though all changes are being applied to the database immediately, however from the outside changes made in a reducer will only be applied to the database once the reducer completes successfully. If you return an error from a reducer or panic within a reducer, all changes made to the database will be rolled back, as if the function had never been called. If you're unfamiliar with atomic transactions, it may not be obvious yet just how useful and important this feature is, but once you build a somewhat complex application it will become clear just how invaluable this feature is. + +--- + +### Publishing the Module + +Now that we have some basic functionality, let's publish the module to SpacetimeDB and call our debug reducer. + +In a new terminal window, run a local version of SpacetimeDB with the command: + +```sh +spacetime start +``` + +This following log output indicates that SpacetimeDB is successfully running on your machine. + +``` +Starting SpacetimeDB listening on 127.0.0.1:3000 +``` + +Now that SpacetimeDB is running we can publish our module to the SpacetimeDB host. In a separate terminal window, navigate to the `blackholio-server/spacetimedb` directory. + +If you are not already logged in to the `spacetime` CLI, run the `spacetime login` command to log in to your SpacetimeDB website account. Once you are logged in, run `spacetime publish --server local blackholio`. This will publish our Blackholio server logic to SpacetimeDB. + +If the publish completed successfully, you will see something like the following in the logs: + +``` +Build finished successfully. +Uploading to local => http://127.0.0.1:3000 +Publishing module... +Created new database with name: blackholio, identity: c200d2c69b4524292b91822afac8ab016c15968ac993c28711f68c6bc40b89d5 +``` + +> If you sign into `spacetime login` via GitHub, the token you get will be issued by `auth.spacetimedb.com`. This will also ensure that you can recover your identity in case you lose it. On the other hand, if you do `spacetime login --server-issued-login local`, you will get an identity which is issued directly by your local server. Do note, however, that `--server-issued-login` tokens are not recoverable if lost, and are only recognized by the server that issued them. + + + +Next, use the `spacetime` command to call our newly defined `Debug` reducer: + +```sh +spacetime call --server local blackholio debug +``` + + +Next, use the `spacetime` command to call our newly defined `debug` reducer: + +```sh +spacetime call --server local blackholio debug +``` + + +Next, use the `spacetime` command to call our newly defined `debug` reducer: + +```sh +spacetime call --server local blackholio debug +``` + + + +If the call completed successfully, that command will have no output, but we can see the debug logs by running: + +```sh +spacetime logs --server local blackholio +``` + +You should see something like the following output: + +```sh +2025-01-09T16:08:38.144299Z INFO: spacetimedb: Creating table `circle` +2025-01-09T16:08:38.144438Z INFO: spacetimedb: Creating table `config` +2025-01-09T16:08:38.144451Z INFO: spacetimedb: Creating table `entity` +2025-01-09T16:08:38.144470Z INFO: spacetimedb: Creating table `food` +2025-01-09T16:08:38.144479Z INFO: spacetimedb: Creating table `player` +2025-01-09T16:08:38.144841Z INFO: spacetimedb: Database initialized +2025-01-09T16:08:47.306823Z INFO: src/lib.rs:68: This reducer was called by c200e1a6494dbeeb0bbf49590b8778abf94fae4ea26faf9769c9a8d69a3ec348. +``` + +### Connecting our Client + + + +Next let's connect our client to our database. Let's start by modifying our `Debug` reducer. Rename the reducer to be called `Connect` and add `ReducerKind.ClientConnected` in parentheses after `Reducer`. The end result should look like this: + +```csharp +[Reducer(ReducerKind.ClientConnected)] +public static void Connect(ReducerContext ctx) +{ + Log.Info($"{ctx.Sender} just connected."); +} +``` + +The `ReducerKind.ClientConnected` argument to the `SpacetimeDB.Reducer` attribute indicates to SpacetimeDB that this is a special reducer. This reducer is only ever called by SpacetimeDB itself when a client connects to your database. + +> SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. +> +> - `ReducerKind.Init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --server local --delete-data`. +> - `ReducerKind.ClientConnected` - Called when a user connects to the SpacetimeDB database. Their identity can be found in the `Sender` value of the `ReducerContext`. +> - `ReducerKind.ClientDisconnected` - Called when a user disconnects from the SpacetimeDB database. + + + +Next let's connect our client to our database. Let's start by modifying our `debug` reducer. Rename the reducer to be called `connect` and add `client_connected` in parentheses after `spacetimedb::reducer`. The end result should look like this: + +```rust +#[spacetimedb::reducer(client_connected)] +pub fn connect(ctx: &ReducerContext) -> Result<(), String> { + log::debug!("{} just connected.", ctx.sender()); + Ok(()) +} +``` + +The `client_connected` argument to the `spacetimedb::reducer` macro indicates to SpacetimeDB that this is a special reducer. This reducer is only ever called by SpacetimeDB itself when a client connects to your database. + +> SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. +> +> - `init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --server local --delete-data`. +> - `client_connected` - Called when a user connects to the SpacetimeDB database. Their identity can be found in the `sender` value of the `ReducerContext`. +> - `client_disconnected` - Called when a user disconnects from the SpacetimeDB database. + + + +Next let's connect our client to our database. Let's start by modifying our `debug` reducer. Rename the reducer to be called `connect` and change `SPACETIMEDB_REDUCER` to `SPACETIMEDB_CLIENT_CONNECTED`. The end result should look like this: + +```cpp +SPACETIMEDB_CLIENT_CONNECTED(connect, ReducerContext ctx) { + LOG_INFO(ctx.sender().to_string() + " just connected."); + return Ok(); +} +``` + +The `SPACETIMEDB_CLIENT_CONNECTED` macro indicates to SpacetimeDB that this is a special reducer. This reducer is only ever called by SpacetimeDB itself when a client connects to your database. + +> SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. +> +> - `SPACETIMEDB_INIT` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --server local --delete-data`. +> - `SPACETIMEDB_CLIENT_CONNECTED` - Called when a user connects to the SpacetimeDB database. Their identity can be found in the `sender` value of the `ReducerContext`. +> - `SPACETIMEDB_CLIENT_DISCONNECTED` - Called when a user disconnects from the SpacetimeDB database. + + + + +Publish your module again by running: + +```sh +spacetime publish --server local blackholio +``` + +### Generating the Client + +The `spacetime` CLI has built in functionality to let us generate C# types that correspond to our tables, types, and reducers that we can use from our Godot client. + +Let's generate our types for our module. In the `blackholio-server/spacetimedb` directory run the following command: + +```sh +spacetime generate --lang csharp --out-dir ../../module_bindings +``` + +This will generate a set of files in the `module_bindings` directory (inside your Godot `blackholio` project directory) which contain the code generated types and reducer functions that are defined in your module, but usable on the client. + +``` +├── Reducers +├── Tables +│ ├── Circle.g.cs +│ ├── Config.g.cs +│ ├── Entity.g.cs +│ ├── Food.g.cs +│ └── Player.g.cs +├── Types +│ ├── Circle.g.cs +│ ├── Config.g.cs +│ ├── DbVector2.g.cs +│ ├── Entity.g.cs +│ ├── Food.g.cs +│ └── Player.g.cs +└── SpacetimeDBClient.g.cs +``` + +This will also generate a file `module_bindings/SpacetimeDBClient.g.cs` with a type aware `DbConnection` class. We will use this class to connect to your database from Godot. + +### Connecting to the Database + +At this point we can connect your Godot client to the server. Replace your imports at the top of the `GameManager.cs` file with: + +```csharp +using System; +using System.Collections.Generic; +using SpacetimeDB; +using SpacetimeDB.Types; +using Godot; +``` + +Replace the implementation of the `GameManager` class with the following. + +```csharp +public partial class GameManager : Node +{ + public static event Action OnConnected; + public static event Action OnSubscriptionApplied; + + [Export] + private string ServerUrl { get; set; } = "http://127.0.0.1:3000"; + + [Export] + private string DatabaseName { get; set; } = "blackholio"; + + [Export] + private Color BackgroundColor { get; set; } = Colors.MidnightBlue; + + [Export] + private float BorderThickness { get; set; } = 5.0f; + + [Export] + private Color BorderColor { get; set; } = Colors.Goldenrod; + + [Export] + private string DefaultPlayerName { get; set; } = "3Blave"; + + private static GameManager Instance { get; set; } + public static Identity LocalIdentity { get; private set; } + public static DbConnection Conn { get; private set; } + + public GameManager() + { + var builder = DbConnection.Builder() + .OnConnect(HandleConnect) + .OnConnectError(HandleConnectError) + .OnDisconnect(HandleDisconnect) + .WithUri(ServerUrl) + .WithDatabaseName(DatabaseName); + + if (AuthToken.TryGetToken(out var authToken)) + { + builder = builder.WithToken(authToken); + } + + Conn = builder.Build(); + STDBUpdateManager.Add(Conn); + } + + public override void _EnterTree() + { + Instance = this; + } + + public override void _ExitTree() + { + Disconnect(); + + if (Instance == this) + { + Instance = null; + } + } + + public static bool IsConnected() => Conn != null && Conn.IsActive; + + private void Disconnect() + { + STDBUpdateManager.Remove(Conn, true); + Conn = null; + } + + // Called when we connect to SpacetimeDB and receive our client identity + private void HandleConnect(DbConnection conn, Identity identity, string token) + { + GD.Print("Connected."); + AuthToken.SaveToken(token); + LocalIdentity = identity; + + OnConnected?.Invoke(); + + // Request all tables + Conn.SubscriptionBuilder() + .OnApplied(HandleSubscriptionApplied) + .SubscribeToAllTables(); + } + + private void HandleConnectError(Exception ex) + { + GD.PrintErr($"Connection error: {ex}"); + } + + private void HandleDisconnect(DbConnection _conn, Exception ex) + { + GD.Print("Disconnected."); + if (ex != null) + { + GD.PrintErr(ex); + } + } + + private void HandleSubscriptionApplied(SubscriptionEventContext ctx) + { + GD.Print("Subscription applied!"); + OnSubscriptionApplied?.Invoke(); + } +} +``` + +Here we configure the connection to the database, by passing it some callbacks in addition to providing the `ServerUrl` and `DatabaseName` to the connection. We add the connection to `STDBUpdateManager`, a simple script that hooks into Godot's update loop in order to drive the sending and processing of messages between your client and SpacetimeDB. + +When the client connects, the SpacetimeDB SDK will call the `HandleConnect` method, allowing us to start up the game. + +In our `HandleConnect` callback we build a subscription and call `Subscribe`, subscribing to all data in the database. This causes SpacetimeDB to synchronize the state of all your tables with your Godot client's SDK client cache. + +--- + +**SDK Client Cache** + +The "SDK client cache" is a client-side view of the database defined by the supplied queries to the `Subscribe` function. SpacetimeDB ensures that the results of subscription queries are automatically updated and pushed to the client cache as they change which allows efficient access without unnecessary server queries. + +--- + +There's only one thing left to do before being able to connect the client and server. Because our SpacetimeDB C# project lives nested in our Godot's project directory, we need to force exclude `blackholio-server` from Godot's C# `.csproj`. Open `Blackholio.csproj` with any text editor and add the following line at the end of the ``: + +``` +$(DefaultItemExcludes);blackholio-server/** +``` + +Your `Blackholio.csproj` should look similar to this one: + +``` + + + net8.0 + net9.0 + true + $(DefaultItemExcludes);blackholio-server/** + + + + + +``` + +Now we're ready. Press the play button in Godot. + +If all went well you should see the below output in your Godot logs. + +``` +SpacetimeDBClient: Connecting to ws://127.0.0.1:3000 blackholio +Connected. +Subscription applied! +``` + +Subscription applied indicates that the SpacetimeDB SDK has evaluated your subscription queries and synchronized your local cache with your database's tables. + +We can also see that the server has logged the connection as well. + +```sh +spacetime logs --server local blackholio +... +2025-01-10T03:51:02.078700Z DEBUG: src/lib.rs:63: c200fb5be9524bfb8289c351516a1d9ea800f70a17a9a6937f11c0ed3854087d just connected. +``` + +### Next Steps + +You've learned how to setup a Godot project with the SpacetimeDB SDK, write a basic SpacetimeDB server module, and how to connect your Godot client to SpacetimeDB. That's pretty much all there is to the setup. You're now ready to start building the game. + +In the [next part](./00400-part-3.md), we'll build out the functionality of the game and you'll learn how to access your table data and call reducers in Godot. diff --git a/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00400-part-3.md b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00400-part-3.md new file mode 100644 index 00000000000..96f4b7e94b5 --- /dev/null +++ b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00400-part-3.md @@ -0,0 +1,1645 @@ +--- +title: 3 - Gameplay +slug: /tutorials/Godot/part-3 +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import { CppModuleVersionNotice } from "@site/src/components/CppModuleVersionNotice"; + + +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! + +This progressive tutorial is continued from [part 2](./00300-part-2.md). + +### Spawning Food + + + +Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `Init` reducer. SpacetimeDB calls the `Init` reducer automatically when you first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportunity to initialize the state of your database before any clients connect. + +Add this new reducer above our `Connect` reducer. + +```csharp +// Note the `ReducerKind.Init` parameter passed to the reducer macro. +// That indicates to SpacetimeDB that it should be called +// once upon database creation. +[Reducer(ReducerKind.Init)] +public static void Init(ReducerContext ctx) +{ + Log.Info($"Initializing..."); + ctx.Db.config.Insert(new Config { world_size = 1000 }); +} +``` + +This reducer also demonstrates how to insert new rows into a table. Here we are adding a single `Config` row to the `config` table with the `Insert` function. + +Now that we've ensured that our database always has a valid `world_size` let's spawn some food into the map. Add the following code to the end of the `Module` class. + +```csharp +const int FOOD_MASS_MIN = 2; +const int FOOD_MASS_MAX = 4; +const int TARGET_FOOD_COUNT = 600; + +public static float MassToRadius(int mass) => MathF.Sqrt(mass); + +[Reducer] +public static void SpawnFood(ReducerContext ctx) +{ + if (ctx.Db.player.Count == 0) //Are there no players yet? + { + return; + } + + var worldSize = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + var rng = ctx.Rng; + var foodCount = ctx.Db.food.Count; + while (foodCount < TARGET_FOOD_COUNT) + { + var foodMass = rng.Range(FOOD_MASS_MIN, FOOD_MASS_MAX); + var foodRadius = MassToRadius(foodMass); + var x = rng.Range(foodRadius, worldSize - foodRadius); + var y = rng.Range(foodRadius, worldSize - foodRadius); + var entity = ctx.Db.entity.Insert(new Entity + { + position = new DbVector2(x, y), + mass = foodMass, + }); + ctx.Db.food.Insert(new Food + { + entity_id = entity.entity_id, + }); + foodCount++; + Log.Info($"Spawned food! {entity.entity_id}"); + } +} + +public static float Range(this Random rng, float min, float max) => rng.NextSingle() * (max - min) + min; + +public static int Range(this Random rng, int min, int max) => (int)rng.NextInt64(min, max); +``` + +In this reducer, we are using the `world_size` we configured along with the `ReducerContext`'s random number generator `.Rng` function to place 600 food uniformly randomly throughout the map. We've also chosen the `mass` of the food to be a random number between 2 and 4 inclusive. + +We also added two helper functions so we can get a random range as either a `int` or a `float`. + + + +Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `init` reducer. SpacetimeDB calls the `init` reducer automatically when first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportGodot to initialize the state of your database before any clients connect. + +Add this new reducer above our `connect` reducer. + +```rust +// Note the `init` parameter passed to the reducer macro. +// That indicates to SpacetimeDB that it should be called +// once upon database creation. +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) -> Result<(), String> { + log::info!("Initializing..."); + ctx.db.config().try_insert(Config { + id: 0, + world_size: 1000, + })?; + Ok(()) +} +``` + +This reducer also demonstrates how to insert new rows into a table. Here we are adding a single `Config` row to the `config` table with the `try_insert` function. `try_insert` returns an error if inserting the row into the table would violate any constraints, like unique constraints, on the table. You can also use `insert` which panics on constraint violations if you know for sure that you will not violate any constraints. + +Now that we've ensured that our database always has a valid `world_size` let's spawn some food into the map. Add the following code to the end of the file. + +```rust +const FOOD_MASS_MIN: i32 = 2; +const FOOD_MASS_MAX: i32 = 4; +const TARGET_FOOD_COUNT: usize = 600; + +fn mass_to_radius(mass: i32) -> f32 { + (mass as f32).sqrt() +} + +#[spacetimedb::reducer] +pub fn spawn_food(ctx: &ReducerContext) -> Result<(), String> { + if ctx.db.player().count() == 0 { + // Are there no logged in players? Skip food spawn. + return Ok(()); + } + + let world_size = ctx + .db + .config() + .id() + .find(0) + .ok_or("Config not found")? + .world_size; + + let mut rng = ctx.rng(); + let mut food_count = ctx.db.food().count(); + while food_count < TARGET_FOOD_COUNT as u64 { + let food_mass = rng.gen_range(FOOD_MASS_MIN..FOOD_MASS_MAX); + let food_radius = mass_to_radius(food_mass); + let x = rng.gen_range(food_radius..world_size as f32 - food_radius); + let y = rng.gen_range(food_radius..world_size as f32 - food_radius); + let entity = ctx.db.entity().try_insert(Entity { + entity_id: 0, + position: DbVector2 { x, y }, + mass: food_mass, + })?; + ctx.db.food().try_insert(Food { + entity_id: entity.entity_id, + })?; + food_count += 1; + log::info!("Spawned food! {}", entity.entity_id); + } + + Ok(()) +} +``` + +In this reducer, we are using the `world_size` we configured along with the `ReducerContext`'s random number generator `.rng()` function to place 600 food uniformly randomly throughout the map. We've also chosen the `mass` of the food to be a random number between 2 and 4 inclusive. + + + + + +Let's start by spawning food into the map. The first thing we need to do is create a new, special reducer called the `SPACETIMEDB_INIT` reducer. SpacetimeDB calls the `SPACETIMEDB_INIT` reducer automatically when you first publish your module, and also after any time you run with `publish --delete-data`. It gives you an opportGodot to initialize the state of your database before any clients connect. + +Add this new reducer above our `connect` reducer. + +```cpp +// Note the SPACETIMEDB_INIT macro. +// This indicates to SpacetimeDB that it should be called +// once upon database creation. +SPACETIMEDB_INIT(init, ReducerContext ctx) { + LOG_INFO("Initializing..."); + ctx.db[config].insert(Config{0, 1000}); + return Ok(); +} +``` + +This reducer also demonstrates how to insert new rows into a table. Here we are adding a single `Config` row to the `config` table with the `insert` function. + +Now that we've ensured that our database always has a valid `world_size` let's spawn some food into the map. Add the following code to the end of the file. + +```cpp +const int32_t FOOD_MASS_MIN = 2; +const int32_t FOOD_MASS_MAX = 4; +const size_t TARGET_FOOD_COUNT = 600; + +float mass_to_radius(int32_t mass) { + return std::sqrt(static_cast(mass)); +} + +SPACETIMEDB_REDUCER(spawn_food, ReducerContext ctx) { + // Check if there are any players logged in + bool has_players = false; + for (const auto& _ : ctx.db[player]) { + has_players = true; + break; + } + if (!has_players) { + // Are there no logged in players? Skip food spawn. + return Ok(); + } + + auto config_opt = ctx.db[config_id].find(0); + if (!config_opt.has_value()) { + return Err("Config not found"); + } + int64_t world_size = config_opt.value().world_size; + + auto& rng = ctx.rng(); + + // Count current food + uint64_t food_count = 0; + for (const auto& _ : ctx.db[food]) { + food_count++; + } + + while (food_count < TARGET_FOOD_COUNT) { + int32_t food_mass = rng.gen_range(FOOD_MASS_MIN, FOOD_MASS_MAX); + float food_radius = mass_to_radius(food_mass); + float x = rng.gen_range(food_radius, static_cast(world_size) - food_radius); + float y = rng.gen_range(food_radius, static_cast(world_size) - food_radius); + + auto inserted_entity = ctx.db[entity].insert(Entity{0, {x, y}, food_mass}); + ctx.db[food].insert(Food{inserted_entity.entity_id}); + + food_count += 1; + LOG_INFO("Spawned food! " + std::to_string(inserted_entity.entity_id)); + } + + return Ok(); +} +``` + +In this reducer, we are using the `world_size` we configured along with the `ReducerContext`'s random number generator `.rng()` function to place 600 food uniformly randomly throughout the map. We've also chosen the `mass` of the food to be a random number between 2 and 4 inclusive. + + + + +Although, we've written the reducer to spawn food, no food will actually be spawned until we call the function while players are logged in. This raises the question, who should call this function and when? + +We would like for this function to be called periodically to "top up" the amount of food on the map so that it never falls very far below our target amount of food. SpacetimeDB has built in functionality for exactly this. With SpacetimeDB you can schedule your module to call itself in the future or repeatedly with reducers. + + + +In order to schedule a reducer to be called we have to create a new table which specifies when and how a reducer should be called. Add this new table to the top of the `Module` class. + +```csharp +[Table(Accessor = "spawn_food_timer", Scheduled = nameof(SpawnFood), ScheduledAt = nameof(scheduled_at))] +public partial struct SpawnFoodTimer +{ + [PrimaryKey, AutoInc] + public ulong scheduled_id; + public ScheduleAt scheduled_at; +} +``` + +Note the `Scheduled = nameof(SpawnFood)` parameter in the table macro. This tells SpacetimeDB that the rows in this table specify a schedule for when the `SpawnFood` reducer should be called. Each schedule table requires a `scheduled_id` and a `scheduled_at` field so that SpacetimeDB can call your reducer, however you can also add your own fields to these rows as well. + + + +In order to schedule a reducer to be called we have to create a new table which specifies when and how a reducer should be called. Add this new table to the top of the file, below your imports. + +```rust +#[spacetimedb::table(accessor = spawn_food_timer, scheduled(spawn_food))] +pub struct SpawnFoodTimer { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + scheduled_at: spacetimedb::ScheduleAt, +} +``` + +Note the `scheduled(spawn_food)` parameter in the table macro. This tells SpacetimeDB that the rows in this table specify a schedule for when the `spawn_food` reducer should be called. Each schedule table requires a `scheduled_id` and a `scheduled_at` field so that SpacetimeDB can call your reducer, however you can also add your own fields to these rows as well. + + + +In order to schedule a reducer to be called we have to create a new table which specifies when and how a reducer should be called. Add this new table to the top of the file, below the Player table. + +```cpp +struct SpawnFoodTimer { + uint64_t scheduled_id; + ScheduleAt scheduled_at; +}; +SPACETIMEDB_STRUCT(SpawnFoodTimer, scheduled_id, scheduled_at); +SPACETIMEDB_TABLE(SpawnFoodTimer, spawn_food_timer, Private); +FIELD_PrimaryKeyAutoInc(spawn_food_timer, scheduled_id); +SPACETIMEDB_SCHEDULE(spawn_food_timer, 1, spawn_food); +``` + +Note the `SPACETIMEDB_SCHEDULE(spawn_food_timer, 1, spawn_food)` call. This tells SpacetimeDB that the rows in this table specify a schedule for when the `spawn_food` reducer should be called. The second parameter `1` is the 0-based column index of the `scheduled_at` field. Each schedule table requires a `scheduled_id` and a `scheduled_at` field so that SpacetimeDB can call your reducer, however you can also add your own fields to these rows as well. + + + +You can create, delete, or change a schedule by inserting, deleting, or updating rows in this table. + + + +You will see an error telling you that the `SpawnFood` reducer needs to take two arguments, but currently only takes one. This is because the schedule row must be passed in to all scheduled reducers. Modify your `SpawnFood` reducer to take the scheduled row as an argument. + +```csharp +[Reducer] +public static void SpawnFood(ReducerContext ctx, SpawnFoodTimer _timer) +{ + // ... +} +``` + + + +You will see an error telling you that the `spawn_food` reducer needs to take two arguments, but currently only takes one. This is because the schedule row must be passed in to all scheduled reducers. Modify your `spawn_food` reducer to take the scheduled row as an argument. + +```rust +#[spacetimedb::reducer] +pub fn spawn_food(ctx: &ReducerContext, _timer: SpawnFoodTimer) -> Result<(), String> { + // ... +} +``` + + + +You will see an error telling you that the `spawn_food` reducer needs to take two arguments, but currently only takes one. This is because the schedule row must be passed in to all scheduled reducers. Modify your `spawn_food` reducer to take the scheduled row as an argument. + +```cpp +SPACETIMEDB_REDUCER(spawn_food, ReducerContext ctx, SpawnFoodTimer _timer) { + // ... +} +``` + + + + +In our case we aren't interested in the data on the row, so we name the argument `_timer`. + + + +Let's modify our `Init` reducer to schedule our `SpawnFood` reducer to be called every 500 milliseconds. + +```csharp +[Reducer(ReducerKind.Init)] +public static void Init(ReducerContext ctx) +{ + Log.Info($"Initializing..."); + ctx.Db.config.Insert(new Config { world_size = 1000 }); + ctx.Db.spawn_food_timer.Insert(new SpawnFoodTimer + { + scheduled_at = new ScheduleAt.Interval(TimeSpan.FromMilliseconds(500)) + }); +} +``` + +:::note + +You can use `ScheduleAt.Interval` to schedule a reducer call at an interval like we're doing here. SpacetimeDB will continue to call the reducer at this interval until you remove the row. You can also use `new ScheduleAt.Time(...)` to schedule a reducer once at a specific time. SpacetimeDB will remove that row automatically after the reducer has been called. + +::: + + + +Let's modify our `init` reducer to schedule our `spawn_food` reducer to be called every 500 milliseconds. + +```rust +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) -> Result<(), String> { + log::info!("Initializing..."); + ctx.db.config().try_insert(Config { + id: 0, + world_size: 1000, + })?; + ctx.db.spawn_food_timer().try_insert(SpawnFoodTimer { + scheduled_id: 0, + scheduled_at: ScheduleAt::Interval(Duration::from_millis(500).into()), + })?; + Ok(()) +} +``` + +:::note + +You can use `ScheduleAt::Interval` to schedule a reducer call at an interval like we're doing here. SpacetimeDB will continue to call the reducer at this interval until you remove the row. You can also use `ScheduleAt::Time()` to specify a specific time at which to call a reducer once. SpacetimeDB will remove that row automatically after the reducer has been called. + +::: + + + +Let's modify our `SPACETIMEDB_INIT` reducer to schedule our `spawn_food` reducer to be called every 500 milliseconds. + +```cpp +SPACETIMEDB_INIT(init, ReducerContext ctx) { + LOG_INFO("Initializing..."); + ctx.db[config].insert(Config{ + 0, + 1000, + }); + ctx.db[spawn_food_timer].insert(SpawnFoodTimer{ + 0, + ScheduleAt(TimeDuration::from_millis(500)), + }); + return Ok(); +} +``` + +:::note +You can use `ScheduleAt(TimeDuration::from_millis(...))` to schedule a reducer call at an interval like we're doing here. SpacetimeDB will continue to call the reducer at this interval until you remove the row. You can also use `ScheduleAt(Timestamp::from_millis_since_epoch(...))` to specify a specific time at which to call a reducer once. SpacetimeDB will remove that row automatically after the reducer has been called. +::: + + + + +### Logging Players In + +Let's continue building out our server module by modifying it to log in a player when they connect to the database, or to create a new player if they've never connected before. + +Let's add a second table to our `Player` struct. Modify the `Player` struct by adding this above the struct: + + + + +```csharp +[Table(Accessor = "logged_out_player")] +``` + + + + +```rust +#[spacetimedb::table(accessor = logged_out_player)] +``` + + + + +```cpp +SPACETIMEDB_TABLE(Player, logged_out_player, Private); +``` + + + + +Your struct should now look like this: + + + + +```csharp +[Table(Accessor = "player", Public = true)] +[Table(Accessor = "logged_out_player")] +public partial struct Player +{ + [PrimaryKey] + public Identity identity; + [Unique, AutoInc] + public int player_id; + public string name; +} +``` + + + + +```rust +#[spacetimedb::table(accessor = player, public)] +#[spacetimedb::table(accessor = logged_out_player)] +#[derive(Debug, Clone)] +pub struct Player { + #[primary_key] + identity: Identity, + #[unique] + #[auto_inc] + player_id: i32, + name: String, +} +``` + + + + +```cpp +struct Player { + Identity identity; + int32_t player_id; + std::string name; +}; +SPACETIMEDB_STRUCT(Player, identity, player_id, name); +SPACETIMEDB_TABLE(Player, player, Public); +SPACETIMEDB_TABLE(Player, logged_out_player, Private); +FIELD_PrimaryKey(player, identity); +FIELD_UniqueAutoInc(player, player_id); +FIELD_PrimaryKey(logged_out_player, identity); +FIELD_UniqueAutoInc(logged_out_player, player_id); +``` + +:::note +In C++, since we're creating two separate tables from the same struct, we need to apply the field constraints (`FIELD_PrimaryKey` and `FIELD_UniqueAutoInc`) to both `player` and `logged_out_player` independently. Each table maintains its own indexes and constraints. +::: + + + + +This creates an additional tabled called `logged_out_player` whose rows share the same `Player` type as in the `player` table. + +:::note + +IMPORTANT! Note that this new table is not marked `public`. This means that it can only be accessed by the database owner (which is almost always the database creator). In order to prevent any unintended data access, all SpacetimeDB tables are private by default. + +If your client isn't syncing rows from the server, check that your table is not accidentally marked private. + +::: + + + +Next, modify your `Connect` reducer and add a new `Disconnect` reducer below it: + +```csharp +[Reducer(ReducerKind.ClientConnected)] +public static void Connect(ReducerContext ctx) +{ + var player = ctx.Db.logged_out_player.identity.Find(ctx.Sender); + if (player != null) + { + ctx.Db.player.Insert(player.Value); + ctx.Db.logged_out_player.identity.Delete(player.Value.identity); + } + else + { + ctx.Db.player.Insert(new Player + { + identity = ctx.Sender, + name = "", + }); + } +} + +[Reducer(ReducerKind.ClientDisconnected)] +public static void Disconnect(ReducerContext ctx) +{ + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); + ctx.Db.logged_out_player.Insert(player); + ctx.Db.player.identity.Delete(player.identity); +} +``` + + + +Next, modify your `connect` reducer and add a new `disconnect` reducer below it: + +```rust +#[spacetimedb::reducer(client_connected)] +pub fn connect(ctx: &ReducerContext) -> Result<(), String> { + if let Some(player) = ctx.db.logged_out_player().identity().find(&ctx.sender()) { + ctx.db.player().insert(player.clone()); + ctx.db + .logged_out_player() + .identity() + .delete(&player.identity); + } else { + ctx.db.player().try_insert(Player { + identity: ctx.sender(), + player_id: 0, + name: String::new(), + })?; + } + Ok(()) +} + +#[spacetimedb::reducer(client_disconnected)] +pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { + let player = ctx + .db + .player() + .identity() + .find(&ctx.sender()) + .ok_or("Player not found")?; + let player_id = player.player_id; + ctx.db.logged_out_player().insert(player); + ctx.db.player().identity().delete(&ctx.sender()); + + Ok(()) +} +``` + + + +Next, modify your `connect` reducer and add a new `disconnect` reducer below it: + +```cpp +SPACETIMEDB_CLIENT_CONNECTED(connect, ReducerContext ctx) { + // Check if this player was previously logged out + auto logged_out_player_opt = ctx.db[logged_out_player_identity].find(ctx.sender()); + if (logged_out_player_opt.has_value()) { + // Move player from logged_out_player to player table + ctx.db[player].insert(logged_out_player_opt.value()); + ctx.db[logged_out_player_identity].delete_by_key(logged_out_player_opt.value().identity); + } else { + // New player - create and insert into player table + ctx.db[player].insert(Player{ctx.sender(), 0, ""}); + } + return Ok(); +} + +SPACETIMEDB_CLIENT_DISCONNECTED(disconnect, ReducerContext ctx) { + // Find the player in the player table + auto player_opt = ctx.db[player_identity].find(ctx.sender()); + if (!player_opt.has_value()) { + return Err("Player not found"); + } + + Player player = player_opt.value(); + + // Move player from player to logged_out_player table + ctx.db[logged_out_player].insert(player); + ctx.db[player_identity].delete_by_key(player.identity); + + return Ok(); +} +``` + + + + +Now when a client connects, if the player corresponding to the client is in the `logged_out_player` table, we will move them into the `player` table, thus indicating that they are logged in and connected. For any new unrecognized client connects we will create a `Player` and insert it into the `player` table. + +When a player disconnects, we will transfer their player row from the `player` table to the `logged_out_player` table to indicate they're offline. + +:::note + +Note that we could have added a `logged_in` boolean to the `Player` type to indicate whether the player is logged in. There's nothing incorrect about that approach, however for several reasons we recommend this two table approach: + +- We can iterate over all logged in players without any `if` statements or branching +- The `Player` type now uses less program memory improving cache efficiency +- We can easily check whether a player is logged in, based on whether their row exists in the `player` table + +This approach is more generally referred to as [existence based processing](https://www.dataorienteddesign.com/dodmain/node4.html) and it is a common technique in data-oriented design. + +::: + +### Spawning Player Circles + +Now that we've got our food spawning and our players set up, let's create a match and spawn player circle entities into it. The first thing we should do before spawning a player into a match is give them a name. + + + +Add the following to the end of the `Module` class. + +```csharp +const int START_PLAYER_MASS = 15; + +[Reducer] +public static void EnterGame(ReducerContext ctx, string name) +{ + Log.Info($"Creating player with name {name}"); + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); + player.name = name; + ctx.Db.player.identity.Update(player); + SpawnPlayerInitialCircle(ctx, player.player_id); +} + +public static Entity SpawnPlayerInitialCircle(ReducerContext ctx, int playerId) +{ + var rng = ctx.Rng; + var worldSize = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + var playerStartRadius = MassToRadius(START_PLAYER_MASS); + var x = rng.Range(playerStartRadius, worldSize - playerStartRadius); + var y = rng.Range(playerStartRadius, worldSize - playerStartRadius); + return SpawnCircleAt( + ctx, + playerId, + START_PLAYER_MASS, + new DbVector2(x, y), + ctx.Timestamp + ); +} + +public static Entity SpawnCircleAt(ReducerContext ctx, int playerId, int mass, DbVector2 position, Timestamp timestamp) +{ + var entity = ctx.Db.entity.Insert(new Entity + { + position = position, + mass = mass, + }); + + ctx.Db.circle.Insert(new Circle + { + entity_id = entity.entity_id, + player_id = playerId, + direction = new DbVector2(0, 1), + speed = 0f, + last_split_time = timestamp, + }); + return entity; +} +``` + +The `EnterGame` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. + +Let's also modify our `Disconnect` reducer to remove the circles from the arena when the player disconnects from the database server. + +```csharp +[Reducer(ReducerKind.ClientDisconnected)] +public static void Disconnect(ReducerContext ctx) +{ + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); + // Remove any circles from the arena + foreach (var circle in ctx.Db.circle.player_id.Filter(player.player_id)) + { + var entity = ctx.Db.entity.entity_id.Find(circle.entity_id) ?? throw new Exception("Could not find circle"); + ctx.Db.entity.entity_id.Delete(entity.entity_id); + ctx.Db.circle.entity_id.Delete(entity.entity_id); + } + ctx.Db.logged_out_player.Insert(player); + ctx.Db.player.identity.Delete(player.identity); +} +``` + + +Add the following to the bottom of your file. + +```rust +const START_PLAYER_MASS: i32 = 15; + +#[spacetimedb::reducer] +pub fn enter_game(ctx: &ReducerContext, name: String) -> Result<(), String> { + log::info!("Creating player with name {}", name); + let mut player: Player = ctx.db.player().identity().find(ctx.sender).ok_or("")?; + let player_id = player.player_id; + player.name = name; + ctx.db.player().identity().update(player); + spawn_player_initial_circle(ctx, player_id)?; + + Ok(()) +} + +fn spawn_player_initial_circle(ctx: &ReducerContext, player_id: i32) -> Result { + let mut rng = ctx.rng(); + let world_size = ctx + .db + .config() + .id() + .find(&0) + .ok_or("Config not found")? + .world_size; + let player_start_radius = mass_to_radius(START_PLAYER_MASS); + let x = rng.gen_range(player_start_radius..(world_size as f32 - player_start_radius)); + let y = rng.gen_range(player_start_radius..(world_size as f32 - player_start_radius)); + spawn_circle_at( + ctx, + player_id, + START_PLAYER_MASS, + DbVector2 { x, y }, + ctx.timestamp, + ) +} + +fn spawn_circle_at( + ctx: &ReducerContext, + player_id: i32, + mass: i32, + position: DbVector2, + timestamp: Timestamp, +) -> Result { + let entity = ctx.db.entity().try_insert(Entity { + entity_id: 0, + position, + mass, + })?; + + ctx.db.circle().try_insert(Circle { + entity_id: entity.entity_id, + player_id, + direction: DbVector2 { x: 0.0, y: 1.0 }, + speed: 0.0, + last_split_time: timestamp, + })?; + Ok(entity) +} +``` + +The `enter_game` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. + +Let's also modify our `disconnect` reducer to remove the circles from the arena when the player disconnects from the database server. + +```rust +#[spacetimedb::reducer(client_disconnected)] +pub fn disconnect(ctx: &ReducerContext) -> Result<(), String> { + let player = ctx + .db + .player() + .identity() + .find(&ctx.sender) + .ok_or("Player not found")?; + let player_id = player.player_id; + ctx.db.logged_out_player().insert(player); + ctx.db.player().identity().delete(&ctx.sender); + + // Remove any circles from the arena + for circle in ctx.db.circle().player_id().filter(&player_id) { + ctx.db.entity().entity_id().delete(&circle.entity_id); + ctx.db.circle().entity_id().delete(&circle.entity_id); + } + + Ok(()) +} +``` + + +Add the following to the bottom of your file. + +```cpp +const int32_t START_PLAYER_MASS = 15; + +// Helper function to spawn a circle at a specific location +Entity spawn_circle_at(ReducerContext& ctx, int32_t player_id, int32_t mass, DbVector2 position, Timestamp timestamp) { + auto inserted_entity = ctx.db[entity].insert(Entity{0, position, mass}); + + ctx.db[circle].insert(Circle{ + inserted_entity.entity_id, + player_id, + DbVector2{0.0f, 1.0f}, // direction + 0.0f, // speed + timestamp // last_split_time + }); + + return inserted_entity; +} + +// Helper function to spawn a player's initial circle +Entity spawn_player_initial_circle(ReducerContext& ctx, int32_t player_id) { + auto config_opt = ctx.db[config_id].find(0); + if (!config_opt.has_value()) { + // This shouldn't happen, but handle it gracefully + return Entity{0, {0.0f, 0.0f}, 0}; + } + int64_t world_size = config_opt.value().world_size; + + auto& rng = ctx.rng(); + float player_start_radius = mass_to_radius(START_PLAYER_MASS); + float x = rng.gen_range(player_start_radius, static_cast(world_size) - player_start_radius); + float y = rng.gen_range(player_start_radius, static_cast(world_size) - player_start_radius); + + return spawn_circle_at(ctx, player_id, START_PLAYER_MASS, DbVector2{x, y}, ctx.timestamp); +} + +SPACETIMEDB_REDUCER(enter_game, ReducerContext ctx, std::string name) { + LOG_INFO("Creating player with name " + name); + + // Find the player + auto player_opt = ctx.db[player_identity].find(ctx.sender()); + if (!player_opt.has_value()) { + return Err("Player not found"); + } + + // Update the player's name + Player updated_player = player_opt.value(); + int32_t player_id = updated_player.player_id; + updated_player.name = name; + ctx.db[player_identity].update(updated_player); + + // Spawn initial circle for the player + spawn_player_initial_circle(ctx, player_id); + + return Ok(); +} +``` + +The `enter_game` reducer takes one argument, the player's `name`. We can use this name to display as a label for the player in the match, by storing the name on the player's row. We are also spawning some circles for the player to control now that they are entering the game. To do this, we choose a random position within the bounds of the arena and create a new entity and corresponding circle row. + +Let's also modify our `disconnect` reducer to remove the circles from the arena when the player disconnects from the database server. + +```cpp +SPACETIMEDB_CLIENT_DISCONNECTED(disconnect, ReducerContext ctx) { + // Find the player in the player table + auto player_opt = ctx.db[player_identity].find(ctx.sender()); + if (!player_opt.has_value()) { + return Err("Player not found"); + } + + Player player = player_opt.value(); + int32_t player_id = player.player_id; + + // Move player from player to logged_out_player table + ctx.db[logged_out_player].insert(player); + ctx.db[player_identity].delete_by_key(player.identity); + + // Remove any circles from the arena + for (const Circle& circle : ctx.db[circle_player_id]) { + if (circle.player_id == player_id) { + ctx.db[entity_entity_id].delete_by_key(circle.entity_id); + ctx.db[circle_entity_id].delete_by_key(circle.entity_id); + } + } + + return Ok(); +} +``` + + + +Finally, publish the new module to SpacetimeDB with this command: + +```sh +spacetime publish --server local blackholio --delete-data +``` + +Deleting the data is optional in this case, but in case you've been messing around with the module we can just start fresh. + +### Creating the Arena + +Now that we've set up our server logic to spawn food and players, let's continue developing our Godot client to display what we have so far. + +Start by adding the `SetupArena` method to your `GameManager` class: + +```csharp +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 + }; + background.AddChild(new Polygon2D + { + Name = "Border", + Color = BorderColor, + Position = Vector2.Zero, + InvertEnabled = true, + InvertBorder = BorderThickness, + Polygon = polygon + }); + AddChild(background); +} +``` + +In your `HandleSubscriptionApplied` let's now call `SetupArena` method. Modify your `HandleSubscriptionApplied` method as in the below. + +```csharp +private void HandleSubscriptionApplied(SubscriptionEventContext ctx) +{ + GD.Print("Subscription applied!"); + OnSubscriptionApplied?.Invoke(); + + // Once we have the initial subscription sync'd to the client cache + // Get the world size from the config table and set up the arena + var worldSize = Conn.Db.Config.Id.Find(0).WorldSize; + SetupArena(worldSize); +} +``` + +The `OnApplied` callback will be called after the server synchronizes the initial state of your tables with your client. Once the sync has happened, we can look up the world size from the `config` table and use it to set up our arena. + +In the **Scene** dock, in Godot, select the `Main` node with the `GameManager` script attached to it and set your background color, border thickness and border color to your preference. + +### Instantiating Nodes + +Now that we have our arena all set up, we need to take the row data that SpacetimeDB syncs with our client and use it to create and draw nodes on the screen. + +Let's start by making some controller scripts for each of the nodes we'd like to have in our scene. In the **FileSystem** dock, right-click and select `New Script`. Select C# and name the new script `PlayerController.cs`. Repeat that same process for `CircleController.cs` and `FoodController.cs`. We'll modify the contents of these files later. + +#### Circle2D + +To render both Circle and Food entities we need a way to draw circles on the screen. Right-click in the **FileSystem** dock, and create a new `Circle2D` C# script: + +```csharp +using Godot; + +public partial class Circle2D : Node2D +{ + private float _radius = 10.0f; + [Export] + public float Radius + { + get => _radius; + set + { + if (Mathf.IsEqualApprox(_radius, value)) return; + + _radius = value; + QueueRedraw(); + } + } + + private Color _color = Colors.Brown; + [Export] + public Color Color + { + get => _color; + set + { + if (_color == value) return; + + _color = value; + QueueRedraw(); + } + } + + public override void _Draw() => DrawCircle(Vector2.Zero, Radius, Color); +} +``` + +#### EntityController + +Let's also create an `EntityController` script which will serve as a base class for both our `CircleController` and `FoodController` classes since both `Circle`s and `Food` are entities. + +Create a new C# script called `EntityController.cs` and replace its contents with: + +```csharp +using Godot; +using SpacetimeDB.Types; + +public abstract partial class EntityController : Circle2D +{ + private const float LerpDurationSec = 0.1f; + + public int EntityId { get; private set; } + + private float LerpTime { get; set; } + private Vector2 LerpStartPosition { get; set; } + private Vector2 TargetPosition { get; set; } + private float TargetRadius { get; set; } + + protected EntityController(int entityId, Color color) + { + EntityId = entityId; + Color = color; + + var entity = GameManager.Conn.Db.Entity.EntityId.Find(entityId); + var position = (Vector2)entity.Position; + LerpStartPosition = position; + TargetPosition = position; + GlobalPosition = position; + Radius = 0; + TargetRadius = MassToRadius(entity.Mass); + } + + public void OnEntityUpdated(Entity newRow) + { + LerpTime = 0.0f; + LerpStartPosition = GlobalPosition; + TargetPosition = (Vector2)newRow.Position; + TargetRadius = MassToRadius(newRow.Mass); + } + + public virtual void OnDelete() => QueueFree(); + + public override void _Process(double delta) + { + var frameDelta = (float)delta; + LerpTime = Mathf.Min(LerpTime + frameDelta, LerpDurationSec); + GlobalPosition = LerpStartPosition.Lerp(TargetPosition, LerpTime / LerpDurationSec); + Radius = Mathf.Lerp(Radius, TargetRadius, frameDelta * 8.0f); + } + + private static float MassToRadius(int mass) => Mathf.Sqrt(mass); +} +``` + +The `EntityController` script inherits from `Circle2D` and it just provides some helper functions and basic functionality to manage and update our client-side entities based on server-side updates. + +> One notable feature is that we linearly interpolate (lerp) between the position where the server says the entity is, and where we actually draw it. This is a common technique which provides for smoother movement. +> +> If you're interested in learning more checkout [this demo](https://gabrielgambetta.com/client-side-prediction-live-demo.html) from Gabriel Gambetta. + +At this point you'll have a compilation error because we can't yet convert from `SpacetimeDB.Types.DbVector2` to `Godot.Vector2`. To fix this, let's also create a new `Extensions.cs` script and replace the contents with: + +```csharp +using Godot; + +namespace SpacetimeDB.Types +{ + public partial class DbVector2 + { + public static implicit operator Vector2(DbVector2 vec) => new(vec.X, vec.Y); + public static implicit operator DbVector2(Vector2 vec) => new(vec.X, vec.Y); + } +} +``` + +This just allows us to implicitly convert between our `DbVector2` type and the Godot `Vector2` type. + +#### CircleController + +Now open the `CircleController` script and modify the contents of the `CircleController` script to be: + +```csharp +using Godot; +using SpacetimeDB.Types; + +public partial class CircleController : EntityController +{ + private static readonly Color[] ColorPalette = + [ + //Yellow + new(175 / 255.0f, 159 / 255.0f, 49 / 255.0f), + new(175 / 255.0f, 116 / 255.0f, 49 / 255.0f), + //Purple + new(112 / 255.0f, 47 / 255.0f, 252 / 255.0f), + new(51 / 255.0f, 91 / 255.0f, 252 / 255.0f), + //Red + new(176 / 255.0f, 54 / 255.0f, 54 / 255.0f), + new(176 / 255.0f, 109 / 255.0f, 54 / 255.0f), + new(141 / 255.0f, 43 / 255.0f, 99 / 255.0f), + //Blue + new(2 / 255.0f, 188 / 255.0f, 250 / 255.0f), + new(7 / 255.0f, 50 / 255.0f, 251 / 255.0f), + new(2 / 255.0f, 28 / 255.0f, 146 / 255.0f) + ]; + + private static CanvasLayer _labelLayer; + private static CanvasLayer LabelLayer + { + get + { + if (_labelLayer == null) + { + + if (Engine.GetMainLoop() is not SceneTree sceneTree) return null; + var root = sceneTree.Root; + if (root == null) return null; + _labelLayer = new CanvasLayer { Name = "CircleLabelLayer" }; + root.AddChild(_labelLayer); + } + return _labelLayer; + } + } + private static Control _labelRoot; + private static Control LabelRoot + { + get + { + if (_labelRoot == null) + { + _labelRoot = new Control + { + Name = "CircleLabelRoot", + MouseFilter = Control.MouseFilterEnum.Ignore + }; + _labelRoot.SetAnchorsPreset(Control.LayoutPreset.FullRect); + + LabelLayer.AddChild(_labelRoot); + } + return _labelRoot; + } + } + + private Label _label; + private Label Label + { + get + { + if (_label == null) + { + _label = new Label + { + Name = $"{Name}_Label", + TopLevel = false, + MouseFilter = Control.MouseFilterEnum.Ignore + }; + LabelRoot.AddChild(_label); + } + return _label; + } + } + + private PlayerController OwnerPlayer { get; set; } + + public CircleController(Circle circle, PlayerController ownerPlayer) : base(circle.EntityId, ColorPalette[circle.PlayerId % ColorPalette.Length]) + { + OwnerPlayer = ownerPlayer; + Label.Text = ownerPlayer.Username; + + ownerPlayer.OnCircleSpawned(this); + } + + public override void _Process(double delta) + { + base._Process(delta); + UpdateScreenLabelPosition(); + } + + public override void OnDelete() + { + base.OnDelete(); + + if (IsInstanceValid(Label)) + { + Label.QueueFree(); + } + + OwnerPlayer?.OnCircleDeleted(this); + } + + private void UpdateScreenLabelPosition() + { + Label.Size = Label.GetCombinedMinimumSize(); + var screenPosition = GetGlobalTransformWithCanvas().Origin; + var offset = new Vector2(0.0f, Radius + 8.0f); + Label.Position = screenPosition + offset - (Label.Size / 2.0f); + } +} +``` + +At the top, we're just defining some possible colors for our circle. We've also created a constructor which takes a `Circle` (same type that's in our `circle` table) and a `PlayerController` which selects a color based on the circle's player Id, as well as setting up the text to show the player's username. + +To show crisp text underneath each cirlce, we lazyly create a global `CanvasLayer` and a `Control` node to have a UI context where we can add labels for each circle. In the `_Process` method, we call `UpdateScreenLabelPosition` to update the Label's position relative to the circle position and radius. + +Note that the `CircleController` inherits from the `EntityController`. + +#### FoodController + +Next open the `FoodController.cs` file and replace the contents with: + +```csharp +using Godot; +using SpacetimeDB.Types; + +public partial class FoodController : EntityController +{ + private static readonly Color[] ColorPalette = + [ + new(119 / 255.0f, 252 / 255.0f, 173 / 255.0f), + new(76 / 255.0f, 250 / 255.0f, 146 / 255.0f), + new(35 / 255.0f, 246 / 255.0f, 120 / 255.0f), + new(119 / 255.0f, 251 / 255.0f, 201 / 255.0f), + new(76 / 255.0f, 249 / 255.0f, 184 / 255.0f), + new(35 / 255.0f, 245 / 255.0f, 165 / 255.0f), + ]; + + public FoodController(Food food) : base(food.EntityId, ColorPalette[food.EntityId % ColorPalette.Length]) { } +} +``` + +This is a much simpler script that only picks a color and calls the base `EntityController`'s constructor. + +#### PlayerController + +Open the `PlayerController` script and modify the contents of the `PlayerController` script to be: + +```csharp +using System.Collections.Generic; +using System.Linq; +using Godot; +using SpacetimeDB.Types; + +public partial class PlayerController : Node +{ + const int SEND_UPDATES_PER_SEC = 20; + const float SEND_UPDATES_FREQUENCY = 1f / SEND_UPDATES_PER_SEC; + + public static PlayerController Local { get; private set; } + + private int _playerId; + private float _lastMovementSendTimestamp; + private Vector2? _lockInputPosition; + private readonly List _ownedCircles = new(); + + private bool _lockInputTogglePressed; + + public string Username => GameManager.Conn.Db.Player.PlayerId.Find(_playerId).Name; + public int NumberOfOwnedCircles => _ownedCircles.Count; + public bool IsLocalPlayer => this == Local; + + public PlayerController(Player player) + { + _playerId = player.PlayerId; + if (player.Identity == GameManager.LocalIdentity) + { + Local = this; + } + } + + public override void _ExitTree() + { + foreach (var circle in _ownedCircles.ToList()) + { + if (IsInstanceValid(circle)) + { + circle.QueueFree(); + } + } + + _ownedCircles.Clear(); + if (Local == this) + { + Local = null; + } + } + + public void OnCircleSpawned(CircleController circle) + { + _ownedCircles.Add(circle); + } + + public void OnCircleDeleted(CircleController deletedCircle) + { + _ownedCircles.Remove(deletedCircle); + } + + public int TotalMass() => _ownedCircles + .Select(circle => GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId)) + .Sum(entity => entity?.Mass ?? 0); + + public bool TryGetCenterOfMass(out Vector2 centerOfMass) + { + if (_ownedCircles.Count == 0) + { + centerOfMass = Vector2.Zero; + return false; + } + + var totalPos = Vector2.Zero; + var totalMass = 0.0f; + foreach (var circle in _ownedCircles) + { + var entity = GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId); + if (entity == null) continue; + + totalPos += circle.GlobalPosition * entity.Mass; + totalMass += entity.Mass; + } + + if (totalMass <= 0) + { + centerOfMass = Vector2.Zero; + return false; + } + + centerOfMass = totalPos / totalMass; + return true; + } +} +``` + +Let's also create a new `Instantiator.cs` script that we can use as a factory to instanciate and update nodes when our database changes. Replace the contents of the file with: + +```csharp +using System.Collections.Generic; +using Godot; +using SpacetimeDB.Types; + +public partial class Instantiator : Node +{ + private DbConnection _conn; + private DbConnection Conn + { + get => _conn; + set + { + if (value == _conn) return; + + if (_conn != null) + { + _conn.Db.Circle.OnInsert -= CircleOnInsert; + _conn.Db.Entity.OnUpdate -= EntityOnUpdate; + _conn.Db.Entity.OnDelete -= EntityOnDelete; + _conn.Db.Food.OnInsert -= FoodOnInsert; + _conn.Db.Player.OnInsert -= PlayerOnInsert; + _conn.Db.Player.OnDelete -= PlayerOnDelete; + } + + _conn = value; + + if (value != null) + { + value.Db.Circle.OnInsert += CircleOnInsert; + value.Db.Entity.OnUpdate += EntityOnUpdate; + value.Db.Entity.OnDelete += EntityOnDelete; + value.Db.Food.OnInsert += FoodOnInsert; + value.Db.Player.OnInsert += PlayerOnInsert; + value.Db.Player.OnDelete += PlayerOnDelete; + } + } + } + + private static Dictionary Entities { get; } = new(); + private static Dictionary Players { get; } = new(); + + public Instantiator(DbConnection conn) + { + Conn = conn; + } + + public override void _ExitTree() + { + GD.PrintErr("Instantiator Exit Tree"); + Conn = null; + } + + private void CircleOnInsert(EventContext context, Circle insertedValue) + { + var player = GetOrCreatePlayer(insertedValue.PlayerId); + var entityController = SpawnCircle(insertedValue, player); + Entities[insertedValue.EntityId] = entityController; + } + + private void EntityOnUpdate(EventContext context, Entity oldEntity, Entity newEntity) + { + if (Entities.TryGetValue(newEntity.EntityId, out var entityController)) + { + entityController.OnEntityUpdated(newEntity); + } + } + + private void EntityOnDelete(EventContext context, Entity oldEntity) + { + if (Entities.Remove(oldEntity.EntityId, out var entityController)) + { + entityController.OnDelete(); + } + } + + private void FoodOnInsert(EventContext context, Food insertedValue) + { + var entityController = SpawnFood(insertedValue); + Entities[insertedValue.EntityId] = entityController; + } + + private void PlayerOnInsert(EventContext context, Player insertedPlayer) + { + GetOrCreatePlayer(insertedPlayer.PlayerId); + } + + private void PlayerOnDelete(EventContext context, Player deletedValue) + { + if (Players.Remove(deletedValue.PlayerId, out var playerController)) + { + playerController.QueueFree(); + } + } + + private PlayerController GetOrCreatePlayer(int playerId) + { + if (!Players.TryGetValue(playerId, out var playerController)) + { + var player = Conn.Db.Player.PlayerId.Find(playerId); + playerController = SpawnPlayer(player); + Players[playerId] = playerController; + } + + return playerController; + } + + private CircleController SpawnCircle(Circle circle, PlayerController owner) + { + var entityController = new CircleController(circle, owner) + { + Name = $"Circle - {circle.EntityId}", + }; + + AddChild(entityController); + + return entityController; + } + + private FoodController SpawnFood(Food food) + { + var entityController = new FoodController(food) + { + Name = $"Food - {food.EntityId}", + }; + + AddChild(entityController); + + return entityController; + } + + private PlayerController SpawnPlayer(Player player) + { + var playerController = new PlayerController(player) + { + Name = $"Player - {player.Name}" + }; + + AddChild(playerController); + + return playerController; + } +} +``` + +In the `Instantiator`'s constructor, we pass down the `DbConnection` and we subscribe to all the relevant changes to the database that we care about. When the `Instatiator` is destroyed and leaves the tree, the `_ExitTree` method is called and we unsubscribe from database changes. + +### Hooking up the Data + +We've now prepared our Godot project so that we can hook up the data from our tables to the Godot game objects and have them drawn on the screen. + +Next lets add an `Instantiator` to our scene when we succesffully connect to SpacetimeDB.Modify the `HandleConnect` method as below. + +```csharp +private void HandleConnect(DbConnection conn, Identity identity, string token) +{ + GD.Print("Connected."); + AuthToken.SaveToken(token); + LocalIdentity = identity; + + OnConnected?.Invoke(); + + AddChild(new Instantiator(conn)); + + // Request all tables + Conn.SubscriptionBuilder() + .OnApplied(HandleSubscriptionApplied) + .SubscribeToAllTables(); +} +``` + +### Camera Controller + +One of the last steps is to create a camera controller to make sure the camera follows the local player around. Create a new script called `CameraController.cs`. Replace the contents of the file with this: + +```csharp +using Godot; + +public partial class CameraController : Camera2D +{ + [Export] + public float BaseVisibleRadius { get; set; } = 50.0f; + + [Export] + public float FollowLerpSpeed { get; set; } = 8.0f; + + [Export] + public float ZoomLerpSpeed { get; set; } = 2.0f; + + private float WorldSize { get; } + + public CameraController(float worldSize) + { + WorldSize = worldSize; + } + + public override void _Process(double delta) + { + Vector2 targetPosition; + if (GameManager.IsConnected() && PlayerController.Local != null && PlayerController.Local.TryGetCenterOfMass(out var centerOfMass)) + { + targetPosition = centerOfMass; + } + else + { + var hWorldSize = WorldSize * 0.5f; + targetPosition = new Vector2(hWorldSize, hWorldSize); + } + + GlobalPosition = GlobalPosition.Lerp(targetPosition, (float)delta * FollowLerpSpeed); + + if (PlayerController.Local == null) + { + return; + } + + var targetCameraSize = CalculateCameraSize(PlayerController.Local); + var desiredZoom = Vector2.One * (BaseVisibleRadius / Mathf.Max(targetCameraSize, 1.0f)); + Zoom = Zoom.Lerp(desiredZoom, (float)delta * ZoomLerpSpeed); + } + + private static float CalculateCameraSize(PlayerController player) => 10.0f + + Mathf.Min(10.0f, player.TotalMass() / 5.0f) + + Mathf.Min(player.NumberOfOwnedCircles - 1, 1) * 30.0f; +} +``` + +Lastly, let's add the `CameraController` to our main scene when we setup the arena. Modify the `SetupArena` method in `GameManager` as follows: + +```csharp +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 + }; + background.AddChild(new Polygon2D + { + Name = "Border", + Color = BorderColor, + Position = Vector2.Zero, + InvertEnabled = true, + InvertBorder = BorderThickness, + Polygon = polygon + }); + AddChild(background, @internal: InternalMode.Front); + + AddChild(new CameraController(worldSize)); +} +``` + +Note that we added a new parameter to the first `AddChild` where we add the `background`: `@internal: InternalMode.Front`. This is to tell Godot to add this child (the background) before the other siblings, so it goes behind the `Instantiator` that we added earlier. + +### Entering the Game + +At this point, you will need to regenerate your bindings. Run the following command from the `blackholio-server/spacetimedb` directory. + +```sh +spacetime generate --lang csharp --out-dir ../../module_bindings +``` + +The last step is to call the `enter_game` reducer on the server, passing in a username for our player, which will spawn a circle for our player. For the sake of simplicity, let's call the `enter_game` reducer from the `HandleSubscriptionApplied` callback with the name "3Blave". + +```csharp + private void HandleSubscriptionApplied(SubscriptionEventContext ctx) + { + GD.Print("Subscription applied!"); + OnSubscriptionApplied?.Invoke(); + + // Once we have the initial subscription sync'd to the client cache + // 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); + } +``` + +### Trying it out + +After publishing our module we can press the play button to see the fruits of our labor! You should be able to see your player's circle, with its username label, surrounded by food. + +![Player on screen](/images/Godot/part-3-player-on-screen.png) + +### Troubleshooting + +- If you get an error when running the generate command, make sure you have an empty subfolder in your Godot project Assets folder called `module_bindings` + +- If you get an error in your Godot console when starting the game, double check that you have published your module and you have the correct module name specified in your `GameManager`. + +### Next Steps + +It's pretty cool to see our player in game surrounded by food, but there's a problem! We can't move yet. In the next part, we'll explore how to get your player moving and interacting with food and other objects. 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 new file mode 100644 index 00000000000..fd36dbb6930 --- /dev/null +++ b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00500-part-4.md @@ -0,0 +1,898 @@ +--- +title: 4 - Moving and Colliding +slug: /tutorials/Godot/part-4 +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import { CppModuleVersionNotice } from "@site/src/components/CppModuleVersionNotice"; + + +Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! + +This progressive tutorial is continued from [part 3](./00400-part-3.md). + +### Moving the player + +At this point, we're very close to having a working game. All we have to do is modify our server to allow the player to move around, and to simulate the physics and collisions of the game. + + + +Let's start by building out a simple math library to help us do collision calculations. Create a new `Math.cs` file in the `blackholio-server/spacetimedb` directory and add the following contents. Let's also remove the `DbVector2` type from `Lib.cs`. + +```csharp +[SpacetimeDB.Type] +public partial struct DbVector2 +{ + public float x; + public float y; + + public DbVector2(float x, float y) + { + this.x = x; + this.y = y; + } + + public float SqrMagnitude => x * x + y * y; + public float Magnitude => MathF.Sqrt(SqrMagnitude); + public DbVector2 Normalized => this / Magnitude; + + public static DbVector2 operator +(DbVector2 a, DbVector2 b) => new DbVector2(a.x + b.x, a.y + b.y); + public static DbVector2 operator -(DbVector2 a, DbVector2 b) => new DbVector2(a.x - b.x, a.y - b.y); + public static DbVector2 operator *(DbVector2 a, float b) => new DbVector2(a.x * b, a.y * b); + public static DbVector2 operator /(DbVector2 a, float b) => new DbVector2(a.x / b, a.y / b); +} +``` + +Next, add the following reducer to the `Module` class of your `Lib.cs` file. + +```csharp +[Reducer] +public static void UpdatePlayerInput(ReducerContext ctx, DbVector2 direction) +{ + var player = ctx.Db.player.identity.Find(ctx.Sender) ?? throw new Exception("Player not found"); + foreach (var c in ctx.Db.circle.player_id.Filter(player.player_id)) + { + var circle = c; + circle.direction = direction.Normalized; + circle.speed = Math.Clamp(direction.Magnitude, 0f, 1f); + ctx.Db.circle.entity_id.Update(circle); + } +} +``` + +This is a simple reducer that takes the movement input from the client and applies it to all circles that the player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.Sender` value is not set by the client. Instead `ctx.Sender` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. + + + +Let's start by building out a simple math library to help us do collision calculations. Create a new `math.rs` file in the `spacetimedb/src` directory and add the following contents. Let's also move the `DbVector2` type from `lib.rs` into this file. + +```rust +use spacetimedb::SpacetimeType; + +// This allows us to store 2D points in tables. +#[derive(SpacetimeType, Debug, Clone, Copy)] +pub struct DbVector2 { + pub x: f32, + pub y: f32, +} + +impl std::ops::Add<&DbVector2> for DbVector2 { + type Output = DbVector2; + + fn add(self, other: &DbVector2) -> DbVector2 { + DbVector2 { + x: self.x + other.x, + y: self.y + other.y, + } + } +} + +impl std::ops::Add for DbVector2 { + type Output = DbVector2; + + fn add(self, other: DbVector2) -> DbVector2 { + DbVector2 { + x: self.x + other.x, + y: self.y + other.y, + } + } +} + +impl std::ops::AddAssign for DbVector2 { + fn add_assign(&mut self, rhs: DbVector2) { + self.x += rhs.x; + self.y += rhs.y; + } +} + +impl std::iter::Sum for DbVector2 { + fn sum>(iter: I) -> Self { + let mut r = DbVector2::new(0.0, 0.0); + for val in iter { + r += val; + } + r + } +} + +impl std::ops::Sub<&DbVector2> for DbVector2 { + type Output = DbVector2; + + fn sub(self, other: &DbVector2) -> DbVector2 { + DbVector2 { + x: self.x - other.x, + y: self.y - other.y, + } + } +} + +impl std::ops::Sub for DbVector2 { + type Output = DbVector2; + + fn sub(self, other: DbVector2) -> DbVector2 { + DbVector2 { + x: self.x - other.x, + y: self.y - other.y, + } + } +} + +impl std::ops::SubAssign for DbVector2 { + fn sub_assign(&mut self, rhs: DbVector2) { + self.x -= rhs.x; + self.y -= rhs.y; + } +} + +impl std::ops::Mul for DbVector2 { + type Output = DbVector2; + + fn mul(self, other: f32) -> DbVector2 { + DbVector2 { + x: self.x * other, + y: self.y * other, + } + } +} + +impl std::ops::Div for DbVector2 { + type Output = DbVector2; + + fn div(self, other: f32) -> DbVector2 { + if other != 0.0 { + DbVector2 { + x: self.x / other, + y: self.y / other, + } + } else { + DbVector2 { x: 0.0, y: 0.0 } + } + } +} + +impl DbVector2 { + pub fn new(x: f32, y: f32) -> Self { + Self { x, y } + } + + pub fn sqr_magnitude(&self) -> f32 { + self.x * self.x + self.y * self.y + } + + pub fn magnitude(&self) -> f32 { + (self.x * self.x + self.y * self.y).sqrt() + } + + pub fn normalized(self) -> DbVector2 { + self / self.magnitude() + } +} +``` + +At the very top of `lib.rs` add the following lines to import the moved `DbVector2` from the `math` module. + +```rust +pub mod math; + +use math::DbVector2; +// ... +``` + +Next, add the following reducer to your `lib.rs` file. + +```rust +#[spacetimedb::reducer] +pub fn update_player_input(ctx: &ReducerContext, direction: DbVector2) -> Result<(), String> { + let player = ctx + .db + .player() + .identity() + .find(&ctx.sender()) + .ok_or("Player not found")?; + for mut circle in ctx.db.circle().player_id().filter(&player.player_id) { + circle.direction = direction.normalized(); + circle.speed = direction.magnitude().clamp(0.0, 1.0); + ctx.db.circle().entity_id().update(circle); + } + Ok(()) +} +``` + +This is a simple reducer that takes the movement input from the client and applies it to all circles that the player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.sender()` value is not set by the client. Instead `ctx.sender()` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. + + + + + + +Let's start by building out a simple math library to help us do collision calculations. Create a new `math.h` file in the `blackholio/spacetimedb/src` directory and add the following contents. We'll also move the `DbVector2` type from `lib.cpp` into this file. + +```cpp +#pragma once + +#include "spacetimedb.h" +#include + +using namespace SpacetimeDB; + +// This allows us to store 2D points in tables. +struct DbVector2 { + float x; + float y; + + // Helper methods + float sqr_magnitude() const { + return x * x + y * y; + } + + float magnitude() const { + return std::sqrt(x * x + y * y); + } + + DbVector2 normalized() const { + float mag = magnitude(); + if (mag != 0.0f) { + return DbVector2{x / mag, y / mag}; + } + return DbVector2{0.0f, 0.0f}; + } + + // Operator overloads + DbVector2 operator+(const DbVector2& other) const { + return DbVector2{x + other.x, y + other.y}; + } + + DbVector2& operator+=(const DbVector2& other) { + x += other.x; + y += other.y; + return *this; + } + + DbVector2 operator-(const DbVector2& other) const { + return DbVector2{x - other.x, y - other.y}; + } + + DbVector2& operator-=(const DbVector2& other) { + x -= other.x; + y -= other.y; + return *this; + } + + DbVector2 operator*(float scalar) const { + return DbVector2{x * scalar, y * scalar}; + } + + DbVector2 operator/(float scalar) const { + if (scalar != 0.0f) { + return DbVector2{x / scalar, y / scalar}; + } + return DbVector2{0.0f, 0.0f}; + } +}; +SPACETIMEDB_STRUCT(DbVector2, x, y); +``` + +At the very top of `lib.cpp` add the following line to include the `DbVector2` from the `math.h` header, and remove the `DbVector2` struct definition from `lib.cpp`: + +```cpp +#include "spacetimedb.h" +#include "math.h" +// ... +``` + +Next, add the following reducer to the end of your `lib.cpp` file. + +```cpp +SPACETIMEDB_REDUCER(update_player_input, ReducerContext ctx, DbVector2 direction) { + // Find the player + auto player_opt = ctx.db[player_identity].find(ctx.sender()); + if (!player_opt.has_value()) { + return Err("Player not found"); + } + + int32_t player_id = player_opt.value().player_id; + + // Update all circles owned by this player + for (Circle circle : ctx.db[circle_player_id].filter(player_id)) { + circle.direction = direction.normalized(); + circle.speed = std::clamp(direction.magnitude(), 0.0f, 1.0f); + ctx.db[circle_entity_id].update(circle); + } + + return Ok(); +} +``` + +This is a simple reducer that takes the movement input from the client and applies it to all circles that the player controls. Note that it is not possible for a player to move another player's circles using this reducer, because the `ctx.sender()` value is not set by the client. Instead `ctx.sender()` is set by SpacetimeDB after it has authenticated that sender. You can rest assured that the caller has been authenticated as that player by the time this reducer is called. + + + +Finally, let's schedule a reducer to run every 50 milliseconds to move the player's circles around based on the most recently set player input. + + + + +```csharp +public partial struct MoveAllPlayersTimer +{ + [PrimaryKey, AutoInc] + public ulong scheduled_id; + public ScheduleAt scheduled_at; +} + +const int START_PLAYER_SPEED = 10; + +public static float MassToMaxMoveSpeed(int mass) => 2f * START_PLAYER_SPEED / (1f + MathF.Sqrt((float)mass / START_PLAYER_MASS)); + +[Reducer] +public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) +{ + var worldSize = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + + // var circleDirections = ctx.Db.circle.Iter().Select(c => (c.entity_id, c.direction * c.speed)).ToDictionary(); + + // Handle player input + foreach (var circle in ctx.Db.circle.Iter()) + { + var checkEntity = ctx.Db.entity.entity_id.Find(circle.entity_id); + if (!checkEntity.HasValue) + { + // This can happen if the circle has been eaten by another circle. + continue; + } + + var circleEntity = checkEntity.Value; + var circleRadius = MassToRadius(circleEntity.mass); + var direction = circle.direction * circle.speed; + var newPosition = circleEntity.position + direction * MassToMaxMoveSpeed(circleEntity.mass); + circleEntity.position.x = Math.Clamp(newPosition.x, circleRadius, worldSize - circleRadius); + circleEntity.position.y = Math.Clamp(newPosition.y, circleRadius, worldSize - circleRadius); + ctx.Db.entity.entity_id.Update(circleEntity); + } +} +``` + + + + +```rust +#[spacetimedb::table(accessor = move_all_players_timer, scheduled(move_all_players))] +pub struct MoveAllPlayersTimer { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + scheduled_at: spacetimedb::ScheduleAt, +} + +const START_PLAYER_SPEED: i32 = 10; + +fn mass_to_max_move_speed(mass: i32) -> f32 { + 2.0 * START_PLAYER_SPEED as f32 / (1.0 + (mass as f32 / START_PLAYER_MASS as f32).sqrt()) +} + +#[spacetimedb::reducer] +pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Result<(), String> { + let world_size = ctx + .db + .config() + .id() + .find(0) + .ok_or("Config not found")? + .world_size; + + // Handle player input + for circle in ctx.db.circle().iter() { + let circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id); + if !circle_entity.is_some() { + // This can happen if a circle is eaten by another circle + continue; + } + let mut circle_entity = circle_entity.unwrap(); + let circle_radius = mass_to_radius(circle_entity.mass); + let direction = circle.direction * circle.speed; + let new_pos = + circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass); + let min = circle_radius; + let max = world_size as f32 - circle_radius; + circle_entity.position.x = new_pos.x.clamp(min, max); + circle_entity.position.y = new_pos.y.clamp(min, max); + ctx.db.entity().entity_id().update(circle_entity); + } + + Ok(()) +} +``` + + + + +```cpp +struct MoveAllPlayersTimer { + uint64_t scheduled_id; + ScheduleAt scheduled_at; +}; +SPACETIMEDB_STRUCT(MoveAllPlayersTimer, scheduled_id, scheduled_at); +SPACETIMEDB_TABLE(MoveAllPlayersTimer, move_all_players_timer, Private); +FIELD_PrimaryKeyAutoInc(move_all_players_timer, scheduled_id); +SPACETIMEDB_SCHEDULE(move_all_players_timer, 1, move_all_players); + +const int32_t START_PLAYER_SPEED = 10; + +float mass_to_max_move_speed(int32_t mass) { + return 2.0f * START_PLAYER_SPEED / (1.0f + std::sqrt(static_cast(mass) / static_cast(START_PLAYER_MASS))); +} + +SPACETIMEDB_REDUCER(move_all_players, ReducerContext ctx, MoveAllPlayersTimer _timer) { + // Get world size from config + auto config_opt = ctx.db[config_id].find(0); + if (!config_opt.has_value()) { + return Err("Config not found"); + } + int64_t world_size = config_opt.value().world_size; + + // Handle player input + for (const Circle& circle : ctx.db[circle]) { + auto circle_entity_opt = ctx.db[entity_entity_id].find(circle.entity_id); + if (!circle_entity_opt.has_value()) { + // This can happen if a circle is eaten by another circle + continue; + } + Entity circle_entity = circle_entity_opt.value(); + float circle_radius = mass_to_radius(circle_entity.mass); + DbVector2 direction = circle.direction * circle.speed; + DbVector2 new_pos = circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass); + float min_bound = circle_radius; + float max_bound = static_cast(world_size) - circle_radius; + circle_entity.position.x = std::clamp(new_pos.x, min_bound, max_bound); + circle_entity.position.y = std::clamp(new_pos.y, min_bound, max_bound); + ctx.db[entity_entity_id].update(circle_entity); + } + + return Ok(); +} +``` + + + + +This reducer is very similar to a standard game "tick" or "frame" that you might find in an ordinary game server or similar to something like the `Update` loop in a game engine like Godot. We've scheduled it every 50 milliseconds and we can use it to step forward our simulation by moving all the circles a little bit further in the direction they're moving. + +In this reducer, we're just looping through all the circles in the game and updating their position based on their direction, speed, and mass. Just basic physics. + + + +Add the following to your `Init` reducer to schedule the `MoveAllPlayers` reducer to run every 50 milliseconds. + +```csharp +ctx.Db.move_all_players_timer.Insert(new MoveAllPlayersTimer +{ + scheduled_at = new ScheduleAt.Interval(TimeSpan.FromMilliseconds(50)) +}); +``` + + + +Add the following to your `init` reducer to schedule the `move_all_players` reducer to run every 50 milliseconds. + +```rust +ctx.db + .move_all_players_timer() + .try_insert(MoveAllPlayersTimer { + scheduled_id: 0, + scheduled_at: ScheduleAt::Interval(Duration::from_millis(50).into()), + })?; +``` + + + +Add the following to your `init` reducer to schedule the `move_all_players` reducer to run every 50 milliseconds. + +```cpp +ctx.db[move_all_players_timer].insert(MoveAllPlayersTimer{ + 0, + ScheduleAt(TimeDuration::from_millis(50)), +}); +``` + + + + +Republish your module with: + +```sh +spacetime publish --server local blackholio --delete-data +``` + +Regenerate your server bindings with: + +```sh +spacetime generate --lang csharp --out-dir ../../module_bindings +``` + +### Moving on the Client + +All that's left is to modify our `PlayerController` on the client to call the `update_player_input` reducer. Open `PlayerController.cs` and add a `_Process` method: + +```csharp +public override void _Process(double delta) +{ + if (!IsLocalPlayer || NumberOfOwnedCircles == 0 || !GameManager.IsConnected()) return; + + var lockTogglePressed = Input.IsPhysicalKeyPressed(Key.Q); + if (lockTogglePressed && !_lockInputTogglePressed) + { + if (_lockInputPosition.HasValue) + { + _lockInputPosition = null; + } + else + { + _lockInputPosition = GetViewport().GetMousePosition(); + } + } + _lockInputTogglePressed = lockTogglePressed; + + var nowSeconds = Time.GetTicksMsec() / 1000.0f; + if (nowSeconds - _lastMovementSendTimestamp < SEND_UPDATES_FREQUENCY) return; + + _lastMovementSendTimestamp = nowSeconds; + + var mousePosition = _lockInputPosition ?? GetViewport().GetMousePosition(); + var screenSize = GetViewport().GetVisibleRect().Size; + var centerOfScreen = screenSize / 2.0f; + var direction = (mousePosition - centerOfScreen) / (screenSize.Y / 3.0f); + + GameManager.Conn.Reducers.UpdatePlayerInput(direction); +} +``` + +Let's try it out! Press play and roam freely around the arena! Now we're cooking with gas. + +### Collisions and Eating Food + +Well this is pretty fun, but wouldn't it be better if we could eat food and grow our circle? Surely, that's going to be a pain, right? + + + +Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `IsOverlapping` helper function which does some basic math based on mass radii, and modify our `MoveAllPlayers` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze. Sometimes, simple is best! + +Add the following code to the `Module` class of your `Lib.cs` file and make sure to replace the existing `MoveAllPlayers` reducer. + +```csharp +private const float MINIMUM_SAFE_MASS_RATIO = 0.85f; + +public static bool IsOverlapping(Entity a, Entity b) +{ + var dx = a.position.x - b.position.x; + var dy = a.position.y - b.position.y; + var distanceSq = dx * dx + dy * dy; + + var aRadius = MassToRadius(a.mass); + var bRadius = MassToRadius(b.mass); + + // If the distance between the two circle centers is less than the + // maximum radius, then the center of the smaller circle is inside + // the larger circle. This gives some leeway for the circles to overlap + // before being eaten. + var maxRadius = aRadius > bRadius ? aRadius: bRadius; + return distanceSq <= maxRadius * maxRadius; +} + +[Reducer] +public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) +{ + var worldSize = (ctx.Db.config.id.Find(0) ?? throw new Exception("Config not found")).world_size; + + // Handle player input + foreach (var circle in ctx.Db.circle.Iter()) + { + var checkEntity = ctx.Db.entity.entity_id.Find(circle.entity_id); + if (checkEntity == null) + { + // This can happen if the circle has been eaten by another circle. + continue; + } + var circleEntity = checkEntity.Value; + var circleRadius = MassToRadius(circleEntity.mass); + var direction = circle.direction * circle.speed; + var newPosition = circleEntity.position + direction * MassToMaxMoveSpeed(circleEntity.mass); + circleEntity.position.x = Math.Clamp(newPosition.x, circleRadius, worldSize - circleRadius); + circleEntity.position.y = Math.Clamp(newPosition.y, circleRadius, worldSize - circleRadius); + + // Check collisions + foreach (var entity in ctx.Db.entity.Iter()) + { + if (entity.entity_id == circleEntity.entity_id || !IsOverlapping(circleEntity, entity)) continue; + + // Check to see if we're overlapping with food + if (ctx.Db.food.entity_id.Find(entity.entity_id).HasValue) { + ctx.Db.entity.entity_id.Delete(entity.entity_id); + ctx.Db.food.entity_id.Delete(entity.entity_id); + circleEntity.mass += entity.mass; + + continue; + } + + // Check to see if we're overlapping with another circle owned by another player + var otherCircle = ctx.Db.circle.entity_id.Find(entity.entity_id); + if (otherCircle.HasValue && otherCircle.Value.player_id != circle.player_id) + { + var massRatio = (float)entity.mass / circleEntity.mass; + if (massRatio < MINIMUM_SAFE_MASS_RATIO) + { + ctx.Db.entity.entity_id.Delete(entity.entity_id); + ctx.Db.circle.entity_id.Delete(entity.entity_id); + circleEntity.mass += entity.mass; + } + } + } + ctx.Db.entity.entity_id.Update(circleEntity); + } +} +``` + + + +Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `is_overlapping` helper function which does some basic math based on mass radii, and modify our `move_all_players` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze. Sometimes, simple is best! + +Add the following code to your `lib.rs` file and make sure to replace the existing `move_all_players` reducer. + +```rust +const MINIMUM_SAFE_MASS_RATIO: f32 = 0.85; + +fn is_overlapping(a: &Entity, b: &Entity) -> bool { + let dx = a.position.x - b.position.x; + let dy = a.position.y - b.position.y; + let distance_sq = dx * dx + dy * dy; + + let radius_a = mass_to_radius(a.mass); + let radius_b = mass_to_radius(b.mass); + + // If the distance between the two circle centers is less than the + // maximum radius, then the center of the smaller circle is inside + // the larger circle. This gives some leeway for the circles to overlap + // before being eaten. + let max_radius = f32::max(radius_a, radius_b); + distance_sq <= max_radius * max_radius +} + +#[spacetimedb::reducer] +pub fn move_all_players(ctx: &ReducerContext, _timer: MoveAllPlayersTimer) -> Result<(), String> { + let world_size = ctx + .db + .config() + .id() + .find(0) + .ok_or("Config not found")? + .world_size; + + // Handle player input + for circle in ctx.db.circle().iter() { + let circle_entity = ctx.db.entity().entity_id().find(&circle.entity_id); + if !circle_entity.is_some() { + // This can happen if a circle is eaten by another circle + continue; + } + let mut circle_entity = circle_entity.unwrap(); + let circle_radius = mass_to_radius(circle_entity.mass); + let direction = circle.direction * circle.speed; + let new_pos = + circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass); + let min = circle_radius; + let max = world_size as f32 - circle_radius; + circle_entity.position.x = new_pos.x.clamp(min, max); + circle_entity.position.y = new_pos.y.clamp(min, max); + + // Check collisions + for entity in ctx.db.entity().iter() { + if entity.entity_id == circle_entity.entity_id { + continue; + } + if is_overlapping(&circle_entity, &entity) { + // Check to see if we're overlapping with food + if ctx.db.food().entity_id().find(&entity.entity_id).is_some() { + ctx.db.entity().entity_id().delete(&entity.entity_id); + ctx.db.food().entity_id().delete(&entity.entity_id); + circle_entity.mass += entity.mass; + } + + // Check to see if we're overlapping with another circle owned by another player + let other_circle = ctx.db.circle().entity_id().find(&entity.entity_id); + if let Some(other_circle) = other_circle { + if other_circle.player_id != circle.player_id { + let mass_ratio = entity.mass as f32 / circle_entity.mass as f32; + if mass_ratio < MINIMUM_SAFE_MASS_RATIO { + ctx.db.entity().entity_id().delete(&entity.entity_id); + ctx.db.circle().entity_id().delete(&entity.entity_id); + circle_entity.mass += entity.mass; + } + } + } + } + } + ctx.db.entity().entity_id().update(circle_entity); + } + + Ok(()) +} +``` + + + +Wrong. With SpacetimeDB it's extremely easy. All we have to do is add an `is_overlapping` helper function which does some basic math based on mass radii, and modify our `move_all_players` reducer to loop through every entity in the arena for every circle, checking each for overlaps. This may not be the most efficient way to do collision checking (building a quad tree or doing [spatial hashing](https://conkerjo.wordpress.com/2009/06/13/spatial-hashing-implementation-for-fast-2d-collisions/) might be better), but SpacetimeDB is very fast so for this number of entities it'll be a breeze. Sometimes, simple is best! + +Add the following code to your `lib.cpp` file and make sure to replace the existing `move_all_players` reducer. + +```cpp +const float MINIMUM_SAFE_MASS_RATIO = 0.85f; + +bool is_overlapping(const Entity& a, const Entity& b) { + float dx = a.position.x - b.position.x; + float dy = a.position.y - b.position.y; + float distance_sq = dx * dx + dy * dy; + + float radius_a = mass_to_radius(a.mass); + float radius_b = mass_to_radius(b.mass); + + // If the distance between the two circle centers is less than the + // maximum radius, then the center of the smaller circle is inside + // the larger circle. This gives some leeway for the circles to overlap + // before being eaten. + float max_radius = std::max(radius_a, radius_b); + return distance_sq <= max_radius * max_radius; +} + +SPACETIMEDB_REDUCER(move_all_players, ReducerContext ctx, MoveAllPlayersTimer _timer) { + // Get world size from config + auto config_opt = ctx.db[config_id].find(0); + if (!config_opt.has_value()) { + return Err("Config not found"); + } + int64_t world_size = config_opt.value().world_size; + + // Handle player input + for (const Circle& circle : ctx.db[circle]) { + auto circle_entity_opt = ctx.db[entity_entity_id].find(circle.entity_id); + if (!circle_entity_opt.has_value()) { + // This can happen if a circle is eaten by another circle + continue; + } + Entity circle_entity = circle_entity_opt.value(); + float circle_radius = mass_to_radius(circle_entity.mass); + DbVector2 direction = circle.direction * circle.speed; + DbVector2 new_pos = circle_entity.position + direction * mass_to_max_move_speed(circle_entity.mass); + float min_bound = circle_radius; + float max_bound = static_cast(world_size) - circle_radius; + circle_entity.position.x = std::clamp(new_pos.x, min_bound, max_bound); + circle_entity.position.y = std::clamp(new_pos.y, min_bound, max_bound); + + // Check collisions + for (const Entity& entity : ctx.db[entity]) { + if (entity.entity_id == circle_entity.entity_id) { + continue; + } + if (is_overlapping(circle_entity, entity)) { + // Check to see if we're overlapping with food + auto food_opt = ctx.db[food_entity_id].find(entity.entity_id); + if (food_opt.has_value()) { + ctx.db[entity_entity_id].delete_by_key(entity.entity_id); + ctx.db[food_entity_id].delete_by_key(entity.entity_id); + circle_entity.mass += entity.mass; + } + + // Check to see if we're overlapping with another circle owned by another player + auto other_circle_opt = ctx.db[circle_entity_id].find(entity.entity_id); + if (other_circle_opt.has_value()) { + const Circle& other_circle = other_circle_opt.value(); + if (other_circle.player_id != circle.player_id) { + float mass_ratio = static_cast(entity.mass) / static_cast(circle_entity.mass); + if (mass_ratio < MINIMUM_SAFE_MASS_RATIO) { + ctx.db[entity_entity_id].delete_by_key(entity.entity_id); + ctx.db[circle_entity_id].delete_by_key(entity.entity_id); + circle_entity.mass += entity.mass; + } + } + } + } + } + + ctx.db[entity_entity_id].update(circle_entity); + } + + return Ok(); +} +``` + + + + +For every circle, we look at all other entities. If they are overlapping then for food, we add the mass of the food to the circle and delete the food, otherwise if it's a circle we delete the smaller circle and add the mass to the bigger circle. + +That's it. We don't even have to do anything on the client. + +```sh +spacetime publish --server local blackholio +``` + +Just update your module by publishing and you're on your way eating food! Try to see how big you can get! + +We didn't even have to update the client, because our client's `OnDelete` callbacks already handled deleting entities from the scene when they're deleted on the server. SpacetimeDB just synchronizes the state with your client automatically. + +Notice that the food automatically respawns as you vaccuum them up. This is because our scheduled reducer is automatically replacing the food 2 times per second, to ensure that there is always close to 600 food on the map. + +## Connecting to Maincloud + +- Publish to Maincloud `spacetime publish --server maincloud --delete-data` + - `` This name should be unique and cannot contain any special characters other than internal hyphens (`-`). You will have to update the database name in `blackholio-server/spacetime.local.json` to match. +- Update the URL in the Main node to: `https://maincloud.spacetimedb.com` +- Update the database name in the Main node to ``. + +To delete your Maincloud database, you can run: `spacetime delete --server maincloud ` + +# Conclusion + + + + So far you've learned how to configure a new Godot project to work with + SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. + Within the module, you've learned how to create tables, update tables, and + write reducers. You've learned about special reducers like `ClientConnected` + and `Init` and how to created scheduled reducers. You learned how we can + used scheduled reducers to implement a physics simulation right within your + module. + + + So far you've learned how to configure a new Godot project to work with + SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. + Within the module, you've learned how to create tables, update tables, and + write reducers. You've learned about special reducers like + `client_connected` and `init` and how to created scheduled reducers. You + learned how we can used scheduled reducers to implement a physics simulation + right within your module. + + + So far you've learned how to configure a new Godot project to work with SpacetimeDB, how to develop, build, and publish a SpacetimeDB server module. Within the module, you've learned how to create tables, update tables, and write reducers. You've learned about special reducers like `SPACETIMEDB_CLIENT_CONNECTED` and `SPACETIMEDB_INIT` and how to create scheduled reducers. You learned how we can use scheduled reducers to implement a physics simulation right within your module. + + + +You've also learned how to view module logs and connect your client to your database server, call reducers from the client and synchronize the data with client. Finally you learned how to use that synchronized data to draw game objects on the screen, so that we can interact with them and play a game! + +And all of that completely from scratch! + +Our game is still pretty limited in some important ways. The biggest limitation is that the client assumes your username is "3Blave" and doesn't give you a menu or a window to set your username before joining the game. Notably, we do not have a unique constraint on the `name` column, so that does not prevent players to use the same username on the same server. + +In fact, if you build what we have and run multiple clients you already have a (very simple) MMO! You can connect hundreds of players to this arena with SpacetimeDB. Note that you would need to run the client in different computers to use different auth tokens. + +There's still plenty more we can do to build this into a proper game though. For example, you might want to also add + +- Username chooser +- Chat +- Leaderboards +- The ability to split into multiple circles +- Nice animations +- Nice shaders +- Space theme! +- Object Pooling (for FoodController, PlayerController and CircleController) + +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/docs/00100-intro/00300-tutorials/00500-godot-tutorial/_category_.json b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/_category_.json new file mode 100644 index 00000000000..e5d96b0c244 --- /dev/null +++ b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Godot Tutorial", + "collapsed": true, + "link": { + "type": "doc", + "id": "intro/tutorials/godot-tutorial/index" + } +} diff --git a/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/index.md b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/index.md new file mode 100644 index 00000000000..811720e274f --- /dev/null +++ b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/index.md @@ -0,0 +1,42 @@ +--- +title: Godot Tutorial +slug: /tutorials/godot +--- + + +Need help with the tutorial or CLI commands? [Join our Discord server](https://discord.gg/spacetimedb)! + +In this tutorial you'll learn how to build a small-scoped MMORPG in Godot, from scratch, using SpacetimeDB. Although, the game we're going to build is small in scope, it'll scale to hundreds of players and will help you get acquainted with all the features and best practices of SpacetimeDB, while building [a fun little game](https://github.com/ClockworkLabs/Blackholio). + +By the end, you should have a basic understanding of what SpacetimeDB offers for developers making multiplayer games. + +The game is inspired by [agar.io](https://agar.io), but SpacetimeDB themed with some fun twists. If you're not familiar [agar.io](https://agar.io), it's a web game in which you and hundreds of other players compete to cultivate mass to become the largest cell in the Petri dish. + +Our game, called [Blackhol.io](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio), will be similar but space themed. It should give you a great idea of the types of games you can develop easily with SpacetimeDB. + +This tutorial assumes that you have a basic understanding of the Godot Engine, using a command line terminal and programming. We'll give you some CLI commands to execute. If you are using Windows, we recommend using Git Bash or PowerShell. For Mac, we recommend Terminal. + +SpacetimeDB supports Godot 4.6.2 .NET Version or later, and this tutorial has been tested with the following Godot versions: + +- `4.6.2 .NET Version` + +:::warning +Make sure to download and use a .NET Version of the Godot Engine. You will also need the .NET SDK installed on your system. +::: + +Please file an issue [here](https://github.com/clockworklabs/SpacetimeDB/issues) if you encounter an issue with a specific Godot version, but please be aware that the SpacetimeDB team is unable to offer support for issues related to versions of Godot prior to `4.6.2`. + +## Blackhol.io Tutorial - Basic Multiplayer + +First you'll get started with the core client/server setup. For part 2, you'll be able to choose between **Rust** or **C#** for your server module language: + +- [Part 1 - Setup](./00200-part-1.md) +- [Part 2 - Connecting to SpacetimeDB](./00300-part-2.md) +- [Part 3 - Gameplay](./00400-part-3.md) +- [Part 4 - Moving and Colliding](./00500-part-4.md) + +## Blackhol.io Tutorial - Advanced + +If you already have a good understanding of the SpacetimeDB client and server, check out our completed tutorial project! + +[https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio) diff --git a/docs/static/images/godot/part-1-game-manager-script.png b/docs/static/images/godot/part-1-game-manager-script.png new file mode 100644 index 00000000000..e3ea3d770d4 Binary files /dev/null and b/docs/static/images/godot/part-1-game-manager-script.png differ diff --git a/docs/static/images/godot/part-1-godot-create-2d-scene.jpg b/docs/static/images/godot/part-1-godot-create-2d-scene.jpg new file mode 100644 index 00000000000..66b71703a68 Binary files /dev/null and b/docs/static/images/godot/part-1-godot-create-2d-scene.jpg differ diff --git a/docs/static/images/godot/part-1-godot-create-project.jpg b/docs/static/images/godot/part-1-godot-create-project.jpg new file mode 100644 index 00000000000..fbbeea25a7b Binary files /dev/null and b/docs/static/images/godot/part-1-godot-create-project.jpg differ diff --git a/docs/static/images/godot/part-1-godot-project-manager.jpg b/docs/static/images/godot/part-1-godot-project-manager.jpg new file mode 100644 index 00000000000..10f9ffb887b Binary files /dev/null and b/docs/static/images/godot/part-1-godot-project-manager.jpg differ diff --git a/docs/static/images/godot/part-3-player-on-screen.png b/docs/static/images/godot/part-3-player-on-screen.png new file mode 100644 index 00000000000..6e6719a69a4 Binary files /dev/null and b/docs/static/images/godot/part-3-player-on-screen.png differ diff --git a/sdks/csharp/README.md b/sdks/csharp/README.md index 0c8ee2fd49e..282f44de0d2 100644 --- a/sdks/csharp/README.md +++ b/sdks/csharp/README.md @@ -1,16 +1,22 @@ # SpacetimeDB C# SDK ## Overview - -This repository contains the C# and [Unity](https://unity.com/) client SDKs for SpacetimeDB. These SDKs contain all the tools you need to build native clients for SpacetimeDB modules using C#. +This repository contains the C#, [Unity](https://unity.com/) and [Godot](https://godotengine.org/) client SDKs for SpacetimeDB. These SDKs contain all the tools you need to build native clients for SpacetimeDB modules using C#. ## Documentation - +### Unity The Unity SDK uses the same code as the C# SDK. You can find the documentation for the C# SDK in the [C# SDK Reference](https://spacetimedb.com/docs/sdks/c-sharp). For a guided tutorial, see the [C# SDK Quickstart](https://spacetimedb.com/docs/sdks/c-sharp/quickstart). There is also a comprehensive Unity tutorial/demo available: - [Unity Tutorial](https://spacetimedb.com/docs/unity/part-1) Doc -- [Unity Demo](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio) Repo +- [Unity Demo](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio/client-unity) Repo + +### Godot +The Godot SDK uses the same code as the C# SDK. You can find the documentation for the C# SDK in the [C# SDK Reference](https://spacetimedb.com/docs/sdks/c-sharp). For a guided tutorial, see the [C# SDK Quickstart](https://spacetimedb.com/docs/sdks/c-sharp/quickstart). + +There is also a comprehensive Godot tutorial/demo available: +- [Godot Tutorial](https://spacetimedb.com/docs/godot/part-1) Doc +- [Godot Demo](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio/client-godot) Repo ## Internal developer documentation See [`DEVELOP.md`](./DEVELOP.md). diff --git a/sdks/csharp/src/AuthToken.cs b/sdks/csharp/src/AuthToken.cs index d02026c7776..44747a03f84 100644 --- a/sdks/csharp/src/AuthToken.cs +++ b/sdks/csharp/src/AuthToken.cs @@ -13,7 +13,74 @@ SpacetimeDBClient.instance.Connect(AuthToken.Token, "localhost:3000", "basicchat", false); */ -#if !UNITY_5_3_OR_NEWER +#if UNITY_5_3_OR_NEWER +using UnityEngine; + +namespace SpacetimeDB +{ + // This is an optional helper class to store your auth token in PlayerPrefs + // Override GetTokenKey() if you want to use a player pref key specific to your game + public static class AuthToken + { + public static string Token => PlayerPrefs.GetString(GetTokenKey()); + + public static void SaveToken(string token) + { + PlayerPrefs.SetString(GetTokenKey(), token); + } + + private static string GetTokenKey() + { + var key = "spacetimedb.identity_token"; +#if UNITY_EDITOR + // Different editors need different keys + key += $" - {Application.dataPath}"; +#endif + return key; + } + } +} +#elif GODOT +using Godot; + +namespace SpacetimeDB +{ + // This is an optional helper class to store your auth token in PlayerPrefs + // You can use keySuffix to save and retrieve different tokens + public static class AuthToken + { + private const string Path = "user://spacetimedb-token.cfg"; + private const string Section = "stdb"; + private static string Key => "identity_token"; + + private static ConfigFile _config; + private static ConfigFile Config => _config ??= new ConfigFile(); + + public static string GetToken(string keySuffix = null) + { + var key = GetKey(keySuffix); + if(!Config.HasSectionKey(Section, key)) + { + Config.Load(Path); + } + return Config.GetValue(Section, key, default(string)).As(); + } + public static bool TryGetToken(out string result) => TryGetToken(null, out result); + public static bool TryGetToken(string keySuffix, out string result) { + result = GetToken(keySuffix); + return !string.IsNullOrWhiteSpace(result); + } + + public static void SaveToken(string token, string keySuffix = null) + { + Config.SetValue(Section, GetKey(keySuffix), token); + Config.Save(Path); + } + + private static string GetKey(string suffix) => string.IsNullOrWhiteSpace(suffix) ? Key : $"{Key}_{suffix}"; + } +} +#else using System; using System.IO; using System.Linq; @@ -98,31 +165,4 @@ public static void SaveToken(string token) } } } -#else -using UnityEngine; - -namespace SpacetimeDB -{ - // This is an optional helper class to store your auth token in PlayerPrefs - // Override GetTokenKey() if you want to use a player pref key specific to your game - public static class AuthToken - { - public static string Token => PlayerPrefs.GetString(GetTokenKey()); - - public static void SaveToken(string token) - { - PlayerPrefs.SetString(GetTokenKey(), token); - } - - private static string GetTokenKey() - { - var key = "spacetimedb.identity_token"; -#if UNITY_EDITOR - // Different editors need different keys - key += $" - {Application.dataPath}"; -#endif - return key; - } - } -} #endif diff --git a/sdks/csharp/src/GodotDebugLogger.cs b/sdks/csharp/src/GodotDebugLogger.cs new file mode 100644 index 00000000000..43463b4ff1b --- /dev/null +++ b/sdks/csharp/src/GodotDebugLogger.cs @@ -0,0 +1,32 @@ +/* SpacetimeDB logging for Godot + * * This class is only used in Godot projects. + * * + */ +#if GODOT +namespace SpacetimeDB +{ + internal class GodotDebugLogger : ISpacetimeDBLogger + { + public void Debug(string message) => + GD.Print(message); + + public void Trace(string message) => + GD.Print(message); + + public void Info(string message) => + GD.Print(message); + + public void Warn(string message) => + GD.PushWarning(message); + + public void Error(string message) => + GD.PrintErr(message); + + public void Exception(string message) => + GD.PrintErr(message); + + public void Exception(Exception e) => + GD.PushError(e); + } +} +#endif diff --git a/sdks/csharp/src/ISpacetimeDBLogger.cs b/sdks/csharp/src/ISpacetimeDBLogger.cs index e8f01cecf07..7678a03265b 100644 --- a/sdks/csharp/src/ISpacetimeDBLogger.cs +++ b/sdks/csharp/src/ISpacetimeDBLogger.cs @@ -19,6 +19,8 @@ public static class Log #if UNITY_5_3_OR_NEWER new UnityDebugLogger(); +#elif GODOT + new GodotDebugLogger(); #else new ConsoleLogger(); #endif diff --git a/sdks/csharp/src/STDBUpdateManager.cs b/sdks/csharp/src/STDBUpdateManager.cs new file mode 100644 index 00000000000..c2662b5b310 --- /dev/null +++ b/sdks/csharp/src/STDBUpdateManager.cs @@ -0,0 +1,110 @@ +#if GODOT +using System.Collections.Generic; +using Godot; + +namespace SpacetimeDB +{ + public partial class STDBUpdateManager : Node + { + private const string SingletonNodeName = nameof(STDBUpdateManager); + + private static STDBUpdateManager _instance; + private static STDBUpdateManager Instance => EnsureInstance(); + + private List Connections { get; } = new(); + + private static STDBUpdateManager EnsureInstance() + { + if (IsInstanceValid(_instance)) + { + return _instance; + } + + if (Engine.GetMainLoop() is not SceneTree sceneTree) + { + GD.PushWarning($"{SingletonNodeName} could not be created because the SceneTree is not available yet."); + return null; + } + + var root = sceneTree.Root; + if (root == null) + { + GD.PushWarning($"{SingletonNodeName} could not be created because the scene root is not available yet."); + return null; + } + + var existing = root.GetNodeOrNull(SingletonNodeName); + if (existing != null) + { + _instance = existing; + return _instance; + } + + _instance = new STDBUpdateManager + { + Name = SingletonNodeName, + }; + root.AddChild(_instance, false, 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); + return true; + } + + public static bool Remove(IDbConnection conn, bool disconnect = false) + { + if (conn == null) return false; + var connections = Instance?.Connections; + if (connections != null && connections.Remove(conn)) + { + if (disconnect) + { + conn.Disconnect(); + } + + return true; + } + + return false; + } + + public override void _EnterTree() + { + if (_instance != null && _instance != this && IsInstanceValid(_instance)) + { + QueueFree(); + return; + } + + _instance = this; + } + + public override void _ExitTree() + { + foreach (var conn in Connections) + { + conn?.Disconnect(); + } + + if (_instance == this) + { + _instance = null; + } + } + + public override void _Process(double delta) + { + foreach (var conn in Connections) + { + conn?.FrameTick(); + } + } + } +} +#endif