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 index 22c85f7be..96f4b7e94 100644 --- 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 @@ -16,12 +16,12 @@ This progressive tutorial is continued from [part 2](./00300-part-2.md). -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 opportGodot 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 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 `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}"); } } @@ -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 @@ -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,66 +909,66 @@ Now that we've set up our server logic to spawn food and players, let's continue Start by adding the `SetupArena` method to your `GameManager` class: -```cs - 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); - } +```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. -```cs - private void HandleSubscriptionApplied(SubscriptionEventContext ctx) - { - GD.Print("Subscription applied!"); - OnSubscriptionApplied?.Invoke(); +```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); - } + // 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, select the `Main` node with the `GameManager` script and set your background color, border thickness and border color to your preference. +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 process for `CircleController.cs` and `FoodController.cs`. We'll modify the contents of these files later. +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. In the FileSystem dock, right-click and create a new `Circle2D` C# script: +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: -```cs +```csharp using Godot; public partial class Circle2D : Node2D @@ -1020,7 +1011,7 @@ Let's also create an `EntityController` script which will serve as a base class Create a new C# script called `EntityController.cs` and replace its contents with: -```cs +```csharp using Godot; using SpacetimeDB.Types; @@ -1033,7 +1024,7 @@ public abstract partial class EntityController : Circle2D private float LerpTime { get; set; } private Vector2 LerpStartPosition { get; set; } private Vector2 TargetPosition { get; set; } - private float TargetRadius { get; set; } = 1; + private float TargetRadius { get; set; } protected EntityController(int entityId, Color color) { @@ -1049,12 +1040,12 @@ public abstract partial class EntityController : Circle2D TargetRadius = MassToRadius(entity.Mass); } - public void OnEntityUpdated(Entity newVal) + public void OnEntityUpdated(Entity newRow) { LerpTime = 0.0f; LerpStartPosition = GlobalPosition; - TargetPosition = (Vector2)newVal.Position; - TargetRadius = MassToRadius(newVal.Mass); + TargetPosition = (Vector2)newRow.Position; + TargetRadius = MassToRadius(newRow.Mass); } public virtual void OnDelete() => QueueFree(); @@ -1071,19 +1062,15 @@ public abstract partial class EntityController : Circle2D } ``` -The `EntityController` script inherits from `Circle2D` and it just provides some helper functions and basic functionality to manage and update our entities based on updates. - -// TODO: -------------------------- -Explain constructor. -// TODO: -------------------------- +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 `GodotEngine.Vector2`. To fix this, let's also create a new `Extensions.cs` script and replace the contents with: +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: -```cs +```csharp using Godot; namespace SpacetimeDB.Types @@ -1102,7 +1089,7 @@ This just allows us to implicitly convert between our `DbVector2` type and the G Now open the `CircleController` script and modify the contents of the `CircleController` script to be: -```cs +```csharp using Godot; using SpacetimeDB.Types; @@ -1220,11 +1207,9 @@ public partial class CircleController : EntityController } ``` -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 the text of the Circle to be the player's username. +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. -// TODO: ------------------------------ -Explain Label stuff. -// TODO: ------------------------------ +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`. @@ -1232,7 +1217,7 @@ Note that the `CircleController` inherits from the `EntityController`. Next open the `FoodController.cs` file and replace the contents with: -```cs +```csharp using Godot; using SpacetimeDB.Types; @@ -1252,11 +1237,13 @@ public partial class FoodController : EntityController } ``` +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: -```cs +```csharp using System.Collections.Generic; using System.Linq; using Godot; @@ -1348,43 +1335,12 @@ public partial class PlayerController : Node 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); - } } ``` -Let's also add a new `Instantiator.cs` script which we can use as a factory for instancing and updating nodes when our database changes. Replace the contents of the file with: +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: -```cs +```csharp using System.Collections.Generic; using Godot; using SpacetimeDB.Types; @@ -1529,9 +1485,7 @@ public partial class Instantiator : Node } ``` -// TODO: ------------------------------ -Explain subscriptions stuff. -// TODO: ------------------------------ +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 @@ -1539,29 +1493,29 @@ We've now prepared our Godot project so that we can hook up the data from our ta Next lets add an `Instantiator` to our scene when we succesffully connect to SpacetimeDB.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) - { - GD.Print("Connected."); - AuthToken.SaveToken(token); - LocalIdentity = identity; +```csharp +private void HandleConnect(DbConnection conn, Identity identity, string token) +{ + GD.Print("Connected."); + AuthToken.SaveToken(token); + LocalIdentity = identity; - OnConnected?.Invoke(); - - AddChild(new Instantiator(conn)); + OnConnected?.Invoke(); - Conn.SubscriptionBuilder() - .OnApplied(HandleSubscriptionApplied) - .SubscribeToAllTables(); - } + 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 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: +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: -```cs +```csharp using Godot; public partial class CameraController : Camera2D @@ -1615,49 +1569,51 @@ public partial class CameraController : Camera2D Lastly, let's add the `CameraController` to our main scene when we setup the arena. Modify the `SetupArena` method in `GameManager` as follows: -```cs - private void SetupArena(float worldSize) +```csharp +private void SetupArena(float worldSize) +{ + var polygon = new[] { - 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); + 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)); - } + 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 may need to regenerate your bindings the following command from the `blackholio/spacetimedb` directory. +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 +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". -```cs +```csharp private void HandleSubscriptionApplied(SubscriptionEventContext ctx) { GD.Print("Subscription applied!"); @@ -1674,7 +1630,7 @@ The last step is to call the `enter_game` reducer on the server, passing in a us ### Trying it out -At this point, 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. +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) diff --git a/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00500-part-4.md b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00500-part-4.md index 015176437..e910bfe03 100644 --- a/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00500-part-4.md +++ b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00500-part-4.md @@ -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,7 +527,7 @@ spacetime publish --server local blackholio --delete-data Regenerate your server bindings with: ```sh -spacetime generate --lang csharp --out-dir ../module_bindings +spacetime generate --lang csharp --out-dir ../../module_bindings ``` ### Moving on the Client @@ -536,35 +536,35 @@ All that's left is to modify our `PlayerController` on the client to call the `u ```cs public override void _Process(double delta) - { - if (!IsLocalPlayer || NumberOfOwnedCircles == 0 || !GameManager.IsConnected()) return; +{ + 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 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; + var nowSeconds = Time.GetTicksMsec() / 1000.0f; + if (nowSeconds - _lastMovementSendTimestamp < SEND_UPDATES_FREQUENCY) return; - _lastMovementSendTimestamp = nowSeconds; + _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); + 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); - } + GameManager.Conn.Reducers.UpdatePlayerInput(direction); +} ``` Let's try it out! Press play and roam freely around the arena! Now we're cooking with gas. @@ -575,92 +575,88 @@ Well this is pretty fun, but wouldn't it be better if we could eat food and grow -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; @@ -744,9 +740,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; @@ -843,12 +839,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 Main node to: `https://maincloud.spacetimedb.com` - Update the database name in the Main node to ``. - Clear the PlayerPrefs in Start() within `GameManager.cs` @@ -886,7 +882,7 @@ You've also learned how to view module logs and connect your client to your data 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. // --> I believe we did add the unique constraint +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. // --> This errors because they use the same AuthToken @@ -898,5 +894,6 @@ There's still plenty more we can do to build this into a proper game though. For - Nice animations - Nice shaders - Space theme! +- Object Pooling (for FoodController, PlayerController and CircleController) If you have any suggestions or comments on the tutorial, either [open an issue](https://github.com/clockworklabs/SpacetimeDB/issues/new), or join our Discord ([https://discord.gg/SpacetimeDB](https://discord.gg/SpacetimeDB)) and chat with us! diff --git a/docs/static/images/godot/part-3-player-on-screen.png b/docs/static/images/godot/part-3-player-on-screen.png new file mode 100644 index 000000000..6e6719a69 Binary files /dev/null and b/docs/static/images/godot/part-3-player-on-screen.png differ