From ac499021234f283eb08da4e3c7cbb81fe75b7a0d Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 20 Apr 2026 15:44:59 -0500 Subject: [PATCH 01/12] Start working on Blackholio for Godot --- demo/Blackholio/client-godot/.editorconfig | 4 + demo/Blackholio/client-godot/.gitattributes | 2 + demo/Blackholio/client-godot/.gitignore | 3 + demo/Blackholio/client-godot/GameManager.cs | 6 + .../client-godot/GameManager.cs.uid | 1 + .../client-godot/STDBSimpleConnection.cs | 29 + .../client-godot/STDBSimpleConnection.cs.uid | 1 + .../SpacetimeDBConnectionManager.cs | 88 + .../SpacetimeDBConnectionManager.cs.uid | 1 + demo/Blackholio/client-godot/icon.svg | 1 + demo/Blackholio/client-godot/icon.svg.import | 43 + demo/Blackholio/client-godot/main.tscn | 6 + demo/Blackholio/client-godot/project.godot | 28 + .../00500-godot-tutorial/00200-part-1.md | 91 + .../00500-godot-tutorial/00300-part-2.md | 835 +++++++++ .../00500-godot-tutorial/00400-part-3.md | 1564 +++++++++++++++++ .../00500-godot-tutorial/00500-part-4.md | 928 ++++++++++ .../00500-godot-tutorial/_category_.json | 8 + .../00500-godot-tutorial/index.md | 42 + .../godot/part-1-game-manager-script.png | Bin 0 -> 30431 bytes .../godot/part-1-godot-create-2d-scene.jpg | Bin 0 -> 141891 bytes .../godot/part-1-godot-create-project.jpg | Bin 0 -> 102139 bytes .../godot/part-1-godot-project-manager.jpg | Bin 0 -> 54310 bytes 23 files changed, 3681 insertions(+) create mode 100644 demo/Blackholio/client-godot/.editorconfig create mode 100644 demo/Blackholio/client-godot/.gitattributes create mode 100644 demo/Blackholio/client-godot/.gitignore create mode 100644 demo/Blackholio/client-godot/GameManager.cs create mode 100644 demo/Blackholio/client-godot/GameManager.cs.uid create mode 100644 demo/Blackholio/client-godot/STDBSimpleConnection.cs create mode 100644 demo/Blackholio/client-godot/STDBSimpleConnection.cs.uid create mode 100644 demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs create mode 100644 demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs.uid create mode 100644 demo/Blackholio/client-godot/icon.svg create mode 100644 demo/Blackholio/client-godot/icon.svg.import create mode 100644 demo/Blackholio/client-godot/main.tscn create mode 100644 demo/Blackholio/client-godot/project.godot create mode 100644 docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00200-part-1.md create mode 100644 docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00300-part-2.md create mode 100644 docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00400-part-3.md create mode 100644 docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00500-part-4.md create mode 100644 docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/_category_.json create mode 100644 docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/index.md create mode 100644 docs/static/images/godot/part-1-game-manager-script.png create mode 100644 docs/static/images/godot/part-1-godot-create-2d-scene.jpg create mode 100644 docs/static/images/godot/part-1-godot-create-project.jpg create mode 100644 docs/static/images/godot/part-1-godot-project-manager.jpg 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/GameManager.cs b/demo/Blackholio/client-godot/GameManager.cs new file mode 100644 index 00000000000..cadad576d5e --- /dev/null +++ b/demo/Blackholio/client-godot/GameManager.cs @@ -0,0 +1,6 @@ +using Godot; +using System; + +public partial class GameManager : Node +{ +} diff --git a/demo/Blackholio/client-godot/GameManager.cs.uid b/demo/Blackholio/client-godot/GameManager.cs.uid new file mode 100644 index 00000000000..d47cbf7bacd --- /dev/null +++ b/demo/Blackholio/client-godot/GameManager.cs.uid @@ -0,0 +1 @@ +uid://c3evgwekyhyuy diff --git a/demo/Blackholio/client-godot/STDBSimpleConnection.cs b/demo/Blackholio/client-godot/STDBSimpleConnection.cs new file mode 100644 index 00000000000..c4661f6f255 --- /dev/null +++ b/demo/Blackholio/client-godot/STDBSimpleConnection.cs @@ -0,0 +1,29 @@ +#if GODOT +using Godot; + +namespace SpaceTimeDB +{ + [GlobalClass] + public partial class STDBSimpleConnection : SpacetimeDBConnectionManager + { + [Export] + public string Host { get; set; } = "http://localhost:3000"; + [Export] + public string DatabaseName { get; set; } = "quickstart-chat-t8oj3"; + [Export] + public string AuthTokenKey { get; set; } = ".spacetime_csharp_quickstart"; + [Export] + public bool ConnectOnReady { get; set; } = true; + + public override void _Ready() + { + if (ConnectOnReady) + { + ConnectToDatabase(); + } + } + + public void ConnectToDatabase() => ConnectToDatabase(Host, DatabaseName, AuthTokenKey); + } +} +#endif diff --git a/demo/Blackholio/client-godot/STDBSimpleConnection.cs.uid b/demo/Blackholio/client-godot/STDBSimpleConnection.cs.uid new file mode 100644 index 00000000000..e191f6113e1 --- /dev/null +++ b/demo/Blackholio/client-godot/STDBSimpleConnection.cs.uid @@ -0,0 +1 @@ +uid://b4dl7mul5iev2 diff --git a/demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs b/demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs new file mode 100644 index 00000000000..33c19c66b63 --- /dev/null +++ b/demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs @@ -0,0 +1,88 @@ +using Godot; +using SpacetimeDB; +using System; + +[GlobalClass] +public partial class SpacetimeDBConnectionManager : Node +{ + // [Signal] + // public delegate void OnConnectedEventHandler(SpacetimeDBConnectionManager conn); + // public event Action OnConnectedTyped; + // [Signal] + // public delegate void OnConnectionErrorEventHandler(string message); + // public event Action OnConnectionErrorTyped; + // [Signal] + // public delegate void OnDisconnectedEventHandler(string message); + // public event Action OnDisconnectedTyped; + // + // public IDbConnection Connection { get; private set; } + // public bool IsActive => Connection?.IsActive == true; + // private string _authToken; + // public string AuthToken + // { + // get => Connection != null ? _authToken : null; + // private set => _authToken = value; + // } + // public Identity? Identity => Connection?.Identity; + // + // public override void _ExitTree() + // { + // DisconnectFromDatabase(); + // } + // + public void ConnectToDatabase(string host, string databaseName, string authTokenKey) + { + // if (Connection?.IsActive == true) + // { + // GD.PrintErr("SpacetimeDB connection is already active."); + // return; + // } + // + // SpacetimeDB.AuthToken.Init(authTokenKey); // Not sure about this + // + // GD.Print($"Connecting to SpacetimeDB at {host} / {databaseName}"); + // + // Connection = DbConnection.Builder() + // .WithUri(host) + // .WithDatabaseName(databaseName) + // .WithToken(SpacetimeDB.AuthToken.Token) + // .OnConnect(OnConnect) + // .OnConnectError(OnConnectError) + // .OnDisconnect(OnDisconnect) + // .Build(); + } + // + // public void DisconnectFromDatabase() + // { + // if (Connection?.IsActive == true) + // { + // Connection.Disconnect(); + // } + // } + // + // public override void _Process(double delta) + // { + // Connection?.FrameTick(); + // } + // + // private void OnConnect(DbConnection conn, Identity identity, string authToken) + // { + // SpacetimeDB.AuthToken.SaveToken(authToken); + // + // OnConnectedTyped?.Invoke(conn, identity, authToken); + // EmitSignal(SignalName.OnConnected, identity.ToString()); + // } + // + // private void OnConnectError(Exception exception) + // { + // OnConnectionErrorTyped?.Invoke(exception); + // EmitSignal(SignalName.OnConnectionError, exception.ToString()); + // } + // + // private void OnDisconnect(DbConnection conn, Exception exception) + // { + // OnDisconnectedTyped?.Invoke(conn, exception); + // EmitSignal(SignalName.OnDisconnected, exception?.ToString() ?? string.Empty); + // Connection = null; + // } +} diff --git a/demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs.uid b/demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs.uid new file mode 100644 index 00000000000..aa60874a913 --- /dev/null +++ b/demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs.uid @@ -0,0 +1 @@ +uid://cm87rhifcwna1 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..739ceea4a57 --- /dev/null +++ b/demo/Blackholio/client-godot/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bhy3nrxvs0bg" +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..4dfee01afd9 --- /dev/null +++ b/demo/Blackholio/client-godot/main.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://cjb7808stemnn"] + +[ext_resource type="Script" uid="uid://c3evgwekyhyuy" path="res://GameManager.cs" id="1_ig7tw"] + +[node name="Main" type="Node2D" unique_id=126559554] +script = ExtResource("1_ig7tw") diff --git a/demo/Blackholio/client-godot/project.godot b/demo/Blackholio/client-godot/project.godot new file mode 100644 index 00000000000..b66435415d8 --- /dev/null +++ b/demo/Blackholio/client-godot/project.godot @@ -0,0 +1,28 @@ +; 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="client-godot" +run/main_scene="uid://cjb7808stemnn" +config/features=PackedStringArray("4.6", "Forward Plus") +config/icon="res://icon.svg" + +[dotnet] + +project/assembly_name="client-godot" + +[physics] + +3d/physics_engine="Jolt Physics" + +[rendering] + +rendering_device/driver.windows="d3d12" 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..af482dc60bd --- /dev/null +++ b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00200-part-1.md @@ -0,0 +1,91 @@ +--- +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. + +![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 manager which will synchronize your Godot client's state with your SpacetimeDB database in accordance with your subscription queries. + +### Add the GameManager to the Scene + +1. **Create a 2D Scene**: + - In the **Scene** (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 (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. + +### Add the SpacetimeDB Connection Manager + +The `STDBUpdateManager` is a simple script which hooks into the Godot `Update` loop in order to drive the sending and processing of messages between your client and SpacetimeDB. You can mnaually add one as a global autoload but it's not necessary since one will be automatically created when it's needed. You don't have to interact with this script, but it must be present on a single GameObject which is in the scene in order for it to facilitate the processing of messages. + +Add a child node to our Main Scene. Select `STDBConnectionManager` as the Node type. + +When you build a new connection to SpacetimeDB, that connection will be added to and managed by the `STDBUpdateManager` automatically. + +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. 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..3df6b0b735a --- /dev/null +++ b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00300-part-2.md @@ -0,0 +1,835 @@ +--- +title: 2 - Connecting to SpacetimeDB +slug: /tutorials/unity/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 for your Unity project lives +├── Assembly-CSharp.csproj +├── Assets/ +│ └── 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 +``` + +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. + +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 +``` + +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. + + + +Run the following command to initialize the SpacetimeDB server module project with Rust as the language: + +```bash +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. + + + + + +Run the following command to initialize the SpacetimeDB server module project with C++ as the language: + +```bash +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. + + + +### SpacetimeDB Tables + + + +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. + +**Important: Open the `blackholio/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. + +**Important: Open the `blackholio/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. + +**Important: Open the `blackholio/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. +[SpacetimeDB.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 SpacetimeDB.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`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. + +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/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 +``` + + + + +```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 `SpacetimeDB.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 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: + + + +```sh +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. + +``` +├── 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 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. + +> 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. +> +> ```csharp +> namespace System.Runtime.CompilerServices +> { +> internal static class IsExternalInit { } +> } +> ``` +> +> Add this snippet to the bottom of your `GameManager.cs` file in your Unity project. This will hopefully be resolved in Unity soon. + +### Connecting to the Database + +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 +using System; +using System.Collections; +using System.Collections.Generic; +using SpacetimeDB; +using SpacetimeDB.Types; +using UnityEngine; +``` + +Replace the implementation of the `GameManager` class with the following. + +```cs +public class GameManager : MonoBehaviour +{ + const string SERVER_URL = "http://127.0.0.1:3000"; + const string MODULE_NAME = "blackholio"; + + public static event Action OnConnected; + public static event Action OnSubscriptionApplied; + + public float borderThickness = 2; + public Material borderMaterial; + + public static GameManager Instance { get; private set; } + public static Identity LocalIdentity { get; private set; } + public static DbConnection Conn { get; private set; } + + private void Start() + { + Instance = this; + Application.targetFrameRate = 60; + + // In order to build a connection to SpacetimeDB we need to register + // our callbacks and specify a SpacetimeDB server URI and module name. + var builder = DbConnection.Builder() + .OnConnect(HandleConnect) + .OnConnectError(HandleConnectError) + .OnDisconnect(HandleDisconnect) + .WithUri(SERVER_URL) + .WithDatabaseName(MODULE_NAME); + + // If the user has a SpacetimeDB auth token stored in the Unity PlayerPrefs, + // we can use it to authenticate the connection. + if (AuthToken.Token != "") + { + builder = builder.WithToken(AuthToken.Token); + } + + // Building the connection will establish a connection to the SpacetimeDB + // server. + Conn = builder.Build(); + } + + // 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; + + OnConnected?.Invoke(); + + // Request all tables + Conn.SubscriptionBuilder() + .OnApplied(HandleSubscriptionApplied) + .SubscribeToAllTables(); + } + + void HandleConnectError(Exception ex) + { + Debug.LogError($"Connection error: {ex}"); + } + + void HandleDisconnect(DbConnection _conn, Exception ex) + { + Debug.Log("Disconnected."); + if (ex != null) + { + Debug.LogException(ex); + } + } + + private void HandleSubscriptionApplied(SubscriptionEventContext ctx) + { + Debug.Log("Subscription applied!"); + OnSubscriptionApplied?.Invoke(); + } + + public static bool IsConnected() + { + return Conn != null && Conn.IsActive; + } + + public void Disconnect() + { + Conn.Disconnect(); + Conn = null; + } +} +``` + +Here we configure the connection to the database, by passing it some callbacks in addition to providing the `SERVER_URI` and `MODULE_NAME` to the connection. 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 Unity 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. + +--- + +Now we're ready to connect the client and server. Press the play button in Unity. + +If all went well you should see the below output in your Unity 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 Unity project with the SpacetimeDB SDK, write a basic SpacetimeDB server module, and how to connect your Unity 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 Unity. 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..8de2e8d1610 --- /dev/null +++ b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00400-part-3.md @@ -0,0 +1,1564 @@ +--- +title: 3 - Gameplay +slug: /tutorials/unity/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 `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 world_size = (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 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() + { + position = new DbVector2(x, y), + mass = food_mass, + }); + ctx.Db.food.Insert(new Food + { + entity_id = entity.entity_id, + }); + food_count++; + 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 opportunity 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 opportunity 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 an 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 `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. + + + + +```csharp +[Reducer] +public static void SpawnFood(ReducerContext ctx, SpawnFoodTimer _timer) +{ + // ... +} +``` + + + + +```rust +#[spacetimedb::reducer] +pub fn spawn_food(ctx: &ReducerContext, _timer: SpawnFoodTimer) -> Result<(), String> { + // ... +} +``` + + + + +```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 line 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 indicated 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 player_id) +{ + 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); + return SpawnCircleAt( + ctx, + player_id, + 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) +{ + 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 = player_id, + 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. + + + +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. + + + +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. + + + + +```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 + 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 Unity client to display what we have so far. + +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 + } + + 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(); + + // 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 view, select the `GameManager` object. Click on the `Border Material` property and choose `Sprites-Default`. + +### Creating GameObjects + +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 `GameObject`s on the screen. + +Let's start by making some controller scripts for each of the game objects we'd like to have in our scene. In the project window, right-click and select `Create > C# Script`. Name the new script `PlayerController.cs`. Repeat that process for `CircleController.cs` and `FoodController.cs`. We'll modify the contents of these files later. + +Now let's make some prefabs for our game objects. In the scene hierarchy window, create a new `GameObject` by right-clicking and selecting: + +``` +2D Object > Sprites > Circle +``` + +Rename the new game object in the scene to `CirclePrefab`. Next in the `Inspector` window click the `Add Component` button and add the `Circle Controller` script component that we just created. Finally drag the object into the `Project` folder. Once the prefab file is created, delete the `CirclePrefab` object from the scene. We'll use this prefab to draw the circles that a player controls. + +Next repeat that same process for the `FoodPrefab` and `Food Controller` component. + +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 +``` + +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`. + +Finally we need to make the `PlayerPrefab`. In the hierarchy window, create a new `GameObject` by right-clicking and selecting: + +``` +Create Empty +``` + +Rename the game object to `PlayerPrefab`. Next in the `Inspector` window click the `Add Component` button and add the `Player Controller` script component that we just created. Next drag the object into the `Project` folder. Once the prefab file is created, delete the `PlayerPrefab` object from the scene. + +#### 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 file called `EntityController.cs` and replace its contents with: + +```cs +using SpacetimeDB.Types; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Unity.VisualScripting; +using UnityEngine; + +public abstract class EntityController : MonoBehaviour +{ + const float LERP_DURATION_SEC = 0.1f; + + private static readonly int ShaderColorProperty = Shader.PropertyToID("_Color"); + + [DoNotSerialize] public int EntityId; + + protected float LerpTime; + protected Vector3 LerpStartPosition; + protected Vector3 LerpTargetPosition; + protected Vector3 TargetScale; + + protected virtual void Spawn(int entityId) + { + EntityId = entityId; + + var entity = GameManager.Conn.Db.Entity.EntityId.Find(entityId); + LerpStartPosition = LerpTargetPosition = transform.position = (Vector2)entity.Position; + transform.localScale = Vector3.one; + TargetScale = MassToScale(entity.Mass); + } + + public void SetColor(Color color) + { + GetComponent().material.SetColor(ShaderColorProperty, color); + } + + public virtual void OnEntityUpdated(Entity newVal) + { + LerpTime = 0.0f; + LerpStartPosition = transform.position; + LerpTargetPosition = (Vector2)newVal.Position; + TargetScale = MassToScale(newVal.Mass); + } + + public virtual void OnDelete(EventContext context) + { + Destroy(gameObject); + } + + public virtual void Update() + { + // Interpolate position and scale + LerpTime = Mathf.Min(LerpTime + Time.deltaTime, LERP_DURATION_SEC); + transform.position = Vector3.Lerp(LerpStartPosition, LerpTargetPosition, LerpTime / LERP_DURATION_SEC); + transform.localScale = Vector3.Lerp(transform.localScale, TargetScale, Time.deltaTime * 8); + } + + public static Vector3 MassToScale(int mass) + { + var diameter = MassToDiameter(mass); + return new Vector3(diameter, diameter, 1); + } + + public static float MassToRadius(int mass) => Mathf.Sqrt(mass); + public static float MassToDiameter(int mass) => MassToRadius(mass) * 2; +} +``` + +The `EntityController` script just provides some helper functions and basic functionality to manage our game objects based on entity 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 `UnityEngine.Vector2`. To fix this, let's also create a new `Extensions.cs` script and replace the contents with: + +```cs +using SpacetimeDB.Types; +using UnityEngine; + +namespace SpacetimeDB.Types +{ + public partial class DbVector2 + { + public static implicit operator Vector2(DbVector2 vec) + { + return new Vector2(vec.X, vec.Y); + } + + public static implicit operator DbVector2(Vector2 vec) + { + return new DbVector2(vec.x, vec.y); + } + } +} +``` + +This just allows us to implicitly convert between our `DbVector2` type and the Unity `Vector2` type. + +#### CircleController + +Now open the `CircleController` script and modify the contents of the `CircleController` script to be: + +```cs +using System; +using System.Collections.Generic; +using SpacetimeDB; +using SpacetimeDB.Types; +using UnityEngine; + +public class CircleController : EntityController +{ + public static Color[] ColorPalette = new[] + { + //Yellow + (Color)new Color32(175, 159, 49, 255), + (Color)new Color32(175, 116, 49, 255), + + //Purple + (Color)new Color32(112, 47, 252, 255), + (Color)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), + + //Blue + (Color)new Color32(2, 188, 250, 255), + (Color)new Color32(7, 50, 251, 255), + (Color)new Color32(2, 28, 146, 255), + }; + + private PlayerController Owner; + + public void Spawn(Circle circle, PlayerController owner) + { + base.Spawn(circle.EntityId); + SetColor(ColorPalette[circle.PlayerId % ColorPalette.Length]); + + this.Owner = owner; + GetComponentInChildren().text = owner.Username; + } + + public override void OnDelete(EventContext context) + { + base.OnDelete(context); + Owner.OnCircleDeleted(this); + } +} +``` + +At the top, we're just defining some possible colors for our circle. We've also created a spawn function which takes a `Circle` (same type that's in our `circle` table) and a `PlayerController` which sets the color based on the circle's player ID, as well as setting the text of the Cricle to be the player's username. + +Note that the `CircleController` inherits from the `EntityController`, not `MonoBehavior`. + +#### FoodController + +Next open the `FoodController.cs` file and replace the contents with: + +```cs +using SpacetimeDB.Types; +using Unity.VisualScripting; +using UnityEngine; + +public class FoodController : EntityController +{ + public static Color[] ColorPalette = new[] + { + (Color)new Color32(119, 252, 173, 255), + (Color)new Color32(76, 250, 146, 255), + (Color)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), + }; + + public void Spawn(Food food) + { + base.Spawn(food.EntityId); + SetColor(ColorPalette[EntityId % ColorPalette.Length]); + } +} +``` + +#### PlayerController + +Open the `PlayerController` script and modify the contents of the `PlayerController` script to be: + +```cs +using System.Collections.Generic; +using System.Linq; +using SpacetimeDB; +using SpacetimeDB.Types; +using UnityEngine; + +public class PlayerController : MonoBehaviour +{ + 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 List OwnedCircles = new List(); + + public string Username => GameManager.Conn.Db.Player.PlayerId.Find(PlayerId).Name; + public int NumberOfOwnedCircles => OwnedCircles.Count; + public bool IsLocalPlayer => this == Local; + + public void Initialize(Player player) + { + PlayerId = player.PlayerId; + if (player.Identity == GameManager.LocalIdentity) + { + Local = this; + } + } + + private void OnDestroy() + { + // If we have any circles, destroy them + foreach (var circle in OwnedCircles) + { + if (circle != null) + { + Destroy(circle.gameObject); + } + } + OwnedCircles.Clear(); + } + + public void OnCircleSpawned(CircleController circle) + { + OwnedCircles.Add(circle); + } + + public void OnCircleDeleted(CircleController deletedCircle) + { + // This means we got eaten + if (OwnedCircles.Remove(deletedCircle) && IsLocalPlayer && OwnedCircles.Count == 0) + { + // DeathScreen.Instance.SetVisible(true); + } + } + + public int TotalMass() + { + return (int)OwnedCircles + .Select(circle => GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId)) + .Sum(e => e?.Mass ?? 0); //If this entity is being deleted on the same frame that we're moving, we can have a null entity here. + } + + public Vector2? CenterOfMass() + { + if (OwnedCircles.Count == 0) + { + return null; + } + + Vector2 totalPos = Vector2.zero; + float totalMass = 0; + foreach (var circle in OwnedCircles) + { + var entity = GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId); + var position = circle.transform.position; + totalPos += (Vector2)position * entity.Mass; + totalMass += entity.Mass; + } + + return totalPos / totalMass; + } + + private void OnGUI() + { + if (!IsLocalPlayer || !GameManager.IsConnected()) + { + return; + } + + GUI.Label(new Rect(0, 0, 100, 50), $"Total Mass: {TotalMass()}"); + } + + //Automated testing members + private bool testInputEnabled; + private Vector2 testInput; + + public void SetTestInput(Vector2 input) => testInput = input; + public void EnableTestInput() => testInputEnabled = true; +} +``` + +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 +using SpacetimeDB.Types; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +public class PrefabManager : MonoBehaviour +{ + private static PrefabManager Instance; + + public CircleController CirclePrefab; + public FoodController FoodPrefab; + public PlayerController PlayerPrefab; + + private void Awake() + { + Instance = this; + } + + public static CircleController SpawnCircle(Circle circle, PlayerController owner) + { + var entityController = Instantiate(Instance.CirclePrefab); + entityController.name = $"Circle - {circle.EntityId}"; + entityController.Spawn(circle, owner); + owner.OnCircleSpawned(entityController); + return entityController; + } + + public static FoodController SpawnFood(Food food) + { + var entityController = Instantiate(Instance.FoodPrefab); + entityController.name = $"Food - {food.EntityId}"; + entityController.Spawn(food); + return entityController; + } + + public static PlayerController SpawnPlayer(Player player) + { + var playerController = Instantiate(Instance.PlayerPrefab); + playerController.name = $"PlayerController - {player.Name}"; + playerController.Initialize(player); + return playerController; + } +} +``` + +In the scene hierarchy, select the `GameManager` object and add the `Prefab Manager` script as a component to the `GameManager` object. Drag the corresponding `CirclePrefab`, `FoodPrefab`, and `PlayerPrefab` prefabs we created earlier from the project view into their respective slots in the `Prefab Manager`. Save the scene. + +### Hooking up the Data + +We've now prepared our Unity project so that we can hook up the data from our tables to the Unity game objects and have them drawn on the screen. + +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; } + + 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(); + } +``` + +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); + } + + private static void EntityOnUpdate(EventContext context, Entity oldEntity, Entity newEntity) + { + if (!Entities.TryGetValue(newEntity.EntityId, out var entityController)) + { + return; + } + entityController.OnEntityUpdated(newEntity); + } + + private static void EntityOnDelete(EventContext context, Entity oldEntity) + { + if (Entities.Remove(oldEntity.EntityId, out var entityController)) + { + entityController.OnDelete(context); + } + } + + 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 PlayerOnDelete(EventContext context, Player deletedvalue) + { + if (Players.Remove(deletedvalue.PlayerId, out var playerController)) + { + GameObject.Destroy(playerController.gameObject); + } + } + + private static PlayerController GetOrCreatePlayer(int playerId) + { + 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; + } +``` + +### 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 +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +public class CameraController : MonoBehaviour +{ + public static float WorldSize = 0.0f; + + private void LateUpdate() + { + var arenaCenterTransform = new Vector3(WorldSize / 2, WorldSize / 2, -10.0f); + if (PlayerController.Local == null || !GameManager.IsConnected()) + { + // Set the camera to be in middle of the arena if we are not connected or + // there is no local player + transform.position = arenaCenterTransform; + return; + } + + var centerOfMass = PlayerController.Local.CenterOfMass(); + if (centerOfMass.HasValue) + { + // Set the camera to be the center of mass of the local player + // if the local player has one + transform.position = new Vector3 + { + x = centerOfMass.Value.x, + y = centerOfMass.Value.y, + z = transform.position.z + }; + } else { + transform.position = arenaCenterTransform; + } + + float targetCameraSize = CalculateCameraSize(PlayerController.Local); + Camera.main.orthographicSize = Mathf.Lerp(Camera.main.orthographicSize, targetCameraSize, Time.deltaTime * 2); + } + + private float CalculateCameraSize(PlayerController player) + { + return 50f + //Base size + Mathf.Min(50, player.TotalMass() / 5) + //Increase camera size with mass + Mathf.Min(player.NumberOfOwnedCircles - 1, 1) * 30; //Zoom out when player splits + } +} +``` + +Add the `CameraController` as a component to the `Main Camera` object in the scene. + +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; + } +``` + +### Entering the Game + +At this point, you may need to regenerate your bindings the following command from the `blackholio/spacetimedb` directory. + +```sh +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(); + + // 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"); + } +``` + +### 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. + +![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` + +- If you get an error in your Unity 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..5ca13a1b85c --- /dev/null +++ b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00500-part-4.md @@ -0,0 +1,928 @@ +--- +title: 4 - Moving and Colliding +slug: /tutorials/unity/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 `csharp-server` 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 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. + + + +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 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. + + + + + + +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 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. + + + +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 +[Table(Accessor = "move_all_players_timer", Scheduled = nameof(MoveAllPlayers), ScheduledAt = nameof(scheduled_at))] +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 world_size = (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(); + + // 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) + { + // 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); + } +} +``` + + + + +```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 Unity. 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 ../Assets/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 +public void Update() +{ + if (!IsLocalPlayer || NumberOfOwnedCircles == 0) + { + return; + } + + if (Input.GetKeyDown(KeyCode.Q)) + { + if (LockInputPosition.HasValue) + { + LockInputPosition = null; + } + else + { + LockInputPosition = (Vector2)Input.mousePosition; + } + } + + // Throttled input requests + if (Time.time - LastMovementSendTimestamp >= SEND_UPDATES_FREQUENCY) + { + LastMovementSendTimestamp = Time.time; + + var mousePosition = LockInputPosition ?? (Vector2)Input.mousePosition; + var screenSize = new Vector2 + { + x = Screen.width, + y = Screen.height, + }; + var centerOfScreen = screenSize / 2; + + var direction = (mousePosition - centerOfScreen) / (screenSize.y / 3); + if (testInputEnabled) { direction = testInput; } + 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 for SpacetimeDB. + +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 +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 radius_a = MassToRadius(a.mass); + var radius_b = 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; +} + +[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; + + // 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) + { + // 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.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); + + // Check collisions + foreach (var entity in ctx.Db.entity.Iter()) + { + if (entity.entity_id == circle_entity.entity_id) + { + 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) + { + 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.Update(circle_entity); + } +} +``` + + + +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. + +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 for SpacetimeDB. + +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 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 (`-`). +- 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` +- Your `GameManager.cs` should look something like this: + +```csharp +const string SERVER_URL = "https://maincloud.spacetimedb.com"; +const string MODULE_NAME = ""; + +... + +private void Start() +{ + // Clear cached connection data to ensure proper connection + PlayerPrefs.DeleteAll(); + + // Continue with initialization +} +``` + +To delete your Maincloud database, you can run: `spacetime delete --server maincloud ` + +# Conclusion + + + + 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 `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 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 + `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 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! + +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. + +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 +- Nice animations +- Nice shaders +- Space theme! + +Fortunately, we've done that for you! If you'd like to check out the completed tutorial game, with these additional features, you can download it on GitHub: + +[https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio) + +If you have any suggestions or comments on the tutorial, either [open an issue](https://github.com/clockworklabs/SpacetimeDB/issues/new), or join our Discord ([https://discord.gg/SpacetimeDB](https://discord.gg/SpacetimeDB)) and chat with us! diff --git a/docs/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 0000000000000000000000000000000000000000..e3ea3d770d439cdff4306c130920ee4386a76c35 GIT binary patch literal 30431 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 VP--0tlJ>p6*sqoU9r9ns{|n?x$ZP-r literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fbbeea25a7bfecfe92fbd631d80ea0011cfdbe17 GIT binary patch literal 102139 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 Hw}<}#izIGqKk0q}5fzq+#z{+S~nCLqAaCm z@Mv&OI|1wf8~_f%*XsW5Lx6`*NJNZF@->;|ECCnqSBU@s9zG5(0paNkfcz{Tml}^6 z0Ki#KWKl!iI|X#?18#f?CJJi&xANX{$Pi5&wu(0^tYZZ7DYzb=crX<_9I`tU6bPKW zBfueb2_7|MK34F_bO-w*;`ix+&69pbKU}JPov*(YqiI6!u@W_6kmtC1fB61c@Nc~N zD+DLRRpGtAc?$uZkY}DB37Ju5XM-r2j(Jzxrgz1IKP=0`?lxCl4c4Kljo#sZ#)-eJzn=la_=Kuilt>~*grhfNL*-a=+pRu0;p5a~u*|fZ#q4nE4+IdQo z^6q1T{N=@c#kQ@J0iol`7rZxQtG2j3%{wPXn_390nw6OKC$aD5NaarZATI7-^es@#|r=xi49 zY}emimFnFUHWr9<&=C7Ba{!@hx`#(0o`Z+SlB13LR?Y(hmup0-ugikFV1STr3(wa>i9 F7Vo&wr6Jd~3hmpK2wI_K#FBizpe zUa;Bndhtj#o30nq-70HB>X=i2wtweZ1hgiGw^$dkuXHJF_ZNTy-yw-EV(6;Pw%D4q zshHF<#WVFG3fctp*33l@Rlh$4*scG>b_(dF$++DfX~{vTaB^@QR*~y<;$qNW zg>opo!r4Y>s>1>tG5wHWJbm1kZO;(~Y3PacV7`}dDG>T5?fKh zXADojN}`?MKX{3u-`DZCJOB!sFy)tfsEv?}p#2JfpRdQSzY#Jrd1?{69DN2h7(zE* zeMNo_&0KFcp3u3&lO%*m0$Boi{yzcD*Dp(62)ao4)iVm(-&*gyB&4xxG0@)pV)L;T zSzZ1&pp{{l%+ngumB4P=-(qsjP|6%|;}ifOk7e(T?KgG%UgZD7Fo{K3A#6iJEUjkY z{Y|G+KqR!UW-)oXJ}NhaFX&DJop&Hpy)b&Q=U(~p#d0|;OGU8!DIl0on#D}<4kxl zf9y2X3G}PES4(EZgPNhf-}XhdA?zQh!Y3HB#6MCJATuHz+A^81X%Gis=FzorHDk)} zf8vNIToD<3?!vfu;GZO(WM4>7&8Gk^vCmoz7Ch=kM;4-8p(izTz1*<``wt3)|1@U% z$_Tj$KfQ{v-ya&PLBGvh+3aoQ{1BHl2yVI>*{i`EF~t`M)4HgVs(4{v{7=(9GJVj$ z*wnc3?qI?{NdtV9&7}99L|!b-cw9ZfA}-vu$<$S8BjOy)`5hlVReDC^^|TTS$2!Z@ zER{Ezm)|QVET5-51&|=5mvyFpt$PO0~LCkN>(jUb93upWj@CRp}5$T7R|5qmgWIuE!;NNfn5{Q8p zqOir~kiR4QLVuFI!ppUSG+lAMR29r;hbd;AVor*zjd79gtsaD>E-I5=Q}_>N78A<< zgzEd-E%+0XHR4LRW6$q%R8^cPS@x{x#gjaz?%*%Fhn(gZ-9P5Y6)+Uf{;hdIvdZk| zOvi&zHT|Cl^cJmmEz`=+^~aJGmd}(SytKcrKO_`xInF1E#G0lRe$Z<3JE}(9D!*xT zBq%Vdbn!xJWR<}H zyt9ob32FciE*{a@X4Bc8*~#h+Fb( zM~e7tw+e><_n792WG=q#=wrgwS7H9%ZO!NHUa>y#j-NiS`JT1tI>nEKe_fJz+;I}+ zRE>$=Y&G3lj}?4U*0PcP-1y{P%5Ywv!z;=M)P;o#LIFjAUXRCGchX>@@s+ub0j^dt zn~dj{8e2AaUXZE?UnzbYnT%r5*LIWT`hosW(1GPA?V|S9W3fIv`)j3EF&k-_0`s;{ zMCh97>d%(#CC+`(VeL^Jr@?>fLFh~~+o)^Z6GZalsdWIC9=Bc8^{USjZpF+(Ppxmw z4CW-{SoIZp?SW%X@ENbZ>mU30@$EpbgP4xe}tCz7LBkK%{jUcDCdXCMKtEX zb#}d&#`7FxrCV24K$h-_V}2PIrUQznPkLw;#Uto1r^tkmEHT+uzp**UYo=?a^189I zRREE=Sy4-$2|wjFfkt@&spbSrN_g15Pmszp7zwW23}BtlQ=ni96F6)rYi&B$(F zQ~6N5+1hYh(a2v~;6wN|t@(6YrPv^e4-cm%rVD3<5DuOXY6oatYZPZWJMzH^Fm{Oj>YVGx_3Ev{v7dQ6rn7`H;9vHQ zkdhENNa^fRZSu$3Y0?Mk73Mo}v7RIqe+ z^v#yu{LG4iEWJrfvT(AQk!v!Oenz)5o%-N!sJ zZF3Y&knkBTf87)>;Pf^RjjMQeTsTs(+QFNRN_zO_|sm;8`9cb-gQ)9nf8Cbt{+ zlaX}HyL-N}UV^RfQU`u|ku-P$evBPvmCk#8o-tIS;)O>=%aQfXsrrPfOSC%ei(a@r zNuHNS-_IA=9Ue^aAni%s~|a`bW>No?eh zoo=@D<;AVR9BGZW8EWp`L1L+G9`XTwy1X>Y_qbm-6=m67ge7|jOQ84xJ>H8NcAb@YGY6F#%sUgi05TAWt`qY$|;cfG2EPYhCy~d`3Ds}osTA5N42>b>=gN4lPIlf?EJhA&1pka9m;MYJl{1QG(@yWCB8egOOCC10)rMQdF%EnUX z^E~EHONxQ6&*KiZ@)RY0>JHrh-w-95u9+cw>}F8={BN!Pd;5RE;`031e172=3E`h` zif^dBR1Yi^;&~R0KJPUCAo7DYy3>kF$CQQ6O(XY(F8ZawmfaLk$a8T`b_Ype=%5)v zC`b=O$3kzGJ%q$M>43NHY%r;LcPo9i3v#ihB(t>XXJa&~?e^h%6eeVIS!|h~5+Z@}SIgj}CbHuCH`@j}SmFTvCIh7BK39*P%&Q)3ZcFLhMUX(b{JB}6fi|r~JIyP&eG%P$dg?n-1xDpfUUhD&| zaf`b1vfP6Uoe|8PGPV1sfR`c1skGW-3lh=B+G>1*GwL<77Mna*)5((w9VyvNtv{MP zJ{k=1Uy*9{i;C8Jkc**S?5PDaQC3~Sp;UTDF$n5msMoviSw<L6mwJdd~$h*f0CPAj*%sH_5Zi-l!cDp+lNV zFC<`Ld2S--msXoPo2HnVTK*$1wS4kde3PVby#AbS+zrM9Mx%^izw0@8YA=Nzw3PJMTCdC~h`x3TC^pR?g zqfyD{uR+`-W>+uj+TPD!<1{K(zt8cADR0)dEpYhf>44oJ@}~{m1BO?r4@nf%jgg1m z$M9-v$WZNpC!gV5L&(dMl+EvNrYzOZkv!I$E2|-OW1z|Igw0jd9SeR}^DL20!Pm+E z$%PU!nT}%n#cUw&&Aq!z$gXqSguR?{lE^)*PGu(lgW+|o{BI*@SS=lF{yt=YZhaY7 zRFkFCstQskHfxMqG{K6U0t$bvSI+q3tjkQs6ktTFsX=`SxKd^VPW^&&jYgFEV5Pm_oMtUNiMn2%`3#dkMn9-dP;7 z_?sp|tlmdSghapxCJytW%c}=e8y# z*ZzExZs;I`WU=|stfcxKQA+}oC=jN65#FXscNb>2HyS^1Kk#y@=^*iE8Yz%+$2>?= zi6_Z2D~rd8<@W&vsKuSyjB8)~o8CjWEZ6QKTQ5#DC3vLa^S=vq z(Z4fNGMulbv)YFw^<2l7^?c3BZ|HK*`lZ*?`su?9NlDUi*bTmOtKJv8Z-jnd!7a$_ zMdttPqHn)*@9$&USKfOfmpT$@_@7)F-g%DSO1Wzv8ejGr=?9%k^%+}An@Veyk8C}u zZM7^j-4Rrx*Va>O(wGfFpiapI8h1Uc+4o|Yv5gK&N!{yw-C6?OwTRj}*MKCv{6Qf6 z;6p4DD92`bkv57tul=g$jMRaMTTiEP7O?VPS@IB83ff*2wO$!VsMg{6gXH1bE91c~NlZ1sHeF*yC z)M8c;Ra<2CS$%{-Me*rG(DP^^RBhN7{EDj~QuOP61EOGvvp6N-AGs{FZB9ztIUIxPHj< zE7OcGuf=j{Gy5B*GnuPI^5d2tbV-*uQ=C7TAq@I^LNXR;WC?TTL8tx$&)w@gt1RQ? z`u#SfYvuWGW%7BldV#k|J8p_p1SOrp$if)Q%%U;LBWq_!FuFh45z{~4;J&`0;f1#& z`n@LW4U;yJ5wxwLKa|k?9G$@nK5r}~AK|&)8}3$@H1W}q)HVa;iwzU+1l%GI#1@jz2KXtdQMgXpBFH59WG&*_Vce z{sFg|{W-Ar@C6enk^$HHu)&5nah&3bMSH%wDm^ZUx_;E2H(ZBHEfQPqCK9tQhY^Sj zS4IN+8D`Qwr?Byy7Aq1io80C2Ei|ud>>4UUyVl1Am{XqZc=-_b!_Ih>Olu$ zfAY=SWA?)%g-PR?%(#5ZQY4w=7MDvdSZKU6BJ*bALjxd#+oI}_g7$w`k_tY|GAZE6 zZBd=T1?-Xgh7#Y|p6QE)mGV;n#U-Brrwhk{OKW7PsgtKp4s57M?@viGeMu@x)`(*Z%j=l5)H-ruHn{hEgx6NWBYRv35lIkz=L6oLrEZ$>SMPLcL$7stp=N)&N$JK8UVn@AtWLv zBE-ih`uSA-*;7n3_yj<4sZnZLE~sP}T0#Zk8)~d-iVQ=sC{`;QFK3t_tTV3h?kviZhWC;Tc!*5b zF7h7hPpxS;pUP^pI6n*aO`nafmv$#*-#FPyeq(U?K< zO>N&@1AbS=Pj@Z*HMb?y+bCId1g?LfYdA z6cTI-s4VW0Mzw=)@|m+*W%&ZNOj3&Ho&2dpn5!rlR{Jj@Hp?`$PXR&WM{Zu3xz=9g zVF?9A(j!5%_{A9zUIe%B5%7@|9O|lk;-TlD<_=dR3ds=xM6p-Mc zH?f2r>*^7bv7?Xm6)`lWN60+e$HBPq6gcC-CY*S-H3x^t73)#1HYA!B=isRr9$+00 zzH6q5k&lS6Vjyj1mQ^B_OB~k0RRwk)igY;zjJqBaoIne?Lo&>imf=QB*)Ofe-e#=H zHYFgli5s^`$c(qjwYVTSEUfPQo;h!XvX-AIEo>Z&-!Jq+G{sqB`_s0KptMO3mX<-X zBgj3t!wKVUJt9zmBOlP?3Trrl z5UWRcXQ7Zy-YjMhP0ycC;Ll3UXj4orGB1|eE4SK*`Sz-`Dm&<}-4*g15cg`ROJn!`O86`rNLRJd93+k2LgRD5Z^F7}YT+i_0|<9jRwDyE>7na8+R@xcrpf5Cc$A5oT z#R+%{fLTXg+Fp4TYUZ|!eUYfEJ3yIKl_JQ>f9JC2yw8A^0GgL>7#aol>S;!*t}&aY zYhFWs8oB8I_9Xo!Yq=D2vaH``d$o$=$Jfb-itMwa+eaI zUp@seaDly*`(LIM@ZL8-V)*qhnaM8*m1{P!R$<2$&G#pdM&CqpDVzd6UO0K!rcm!; zCd@mAr0Lc4Ukoa^YL~rxXWeJ;&7tQhAiuS{i2MmRMBTZc65rB;8Wch*CZK=|p3y$R zC&r>l3xjVki=@(0{?ym^+~kB_JsJojDIyh%+>9et0EzYAF1=A(0`Cx%EfjG z8b2k7GA&hSn-tMqPB}*$6#J}Ow`i*Em-=5=54_2D;Zi`hglEp0J!YzSsJ^yKlnbSu zaMP`S-P~t9WukhaRItppL*Lx!ab$Ki<_3kVE&L;kUvtWwXb&aT@Yv4*HyIIE~ab6 zucaih4og%M+$?Usn3cwaX`Vja;^KZY8(UIMn_KFc>J7;;Xw9&lo1{`?%FTHvQPUV5 zx`*mF?SdA(9;E}b5#XXNGK9`sf1`3gb&0Y9^Nh;Q$+7p913ky(0t4E{zoyyDQ3Eqzrq zNGmz{z3@bRon+iH%8$CUFDdRiO}KWL=Xr96VFKDFb ziUj<C z`=V#wggzAYF=Q)=JXKLVzs}9G{qcvdGXf(Vp zqo*P8d?yv66%}-dq$+as^_5vH%0c1k#GlO z#AIPrmo+{^Iu(0ZY{ScPb#x@#KXJ~O*AnQUis99*yla}ZCLyuUi)$<3jD}-dVpZf2 z;L>QF0xsQ5sEsoL+VK?dJXmY4Zya)Me2hhobRWDIHdKcH5@T8H3+h-Mk09i!y((2= zsIzNI=aMIbzHt7$CJY2Os;fM91>I^hDy4w9!^3u?{N!+S9K&pCKjdEM|J9uYf1dI}d zwIfsm)iX+L?Msc-nC2WbZy%{}>`#6nVPR8|i4YYqL2v`>O0*{mgokwsXU5n05*3P) z8e(l`{KAs4wOj~+M!Zk4wX)6nqm@}aa$%CGa3nro*pB9$TN9T;>Vzfn2^+4DGti7V z6$88V1kV2!I}o$ATI(_r`L>rgZY>zLdHWP+NCW5xVI4B&ApnVdtO}Hg>X&Z0v3yY zL~qc8th*nwYY9#PBL>^|&lao!XGW||@*@lcX*6L%96cC4&%5`6@A4~!pjY)V;DrKl zm~vxh#NNu=%zI{TlC_hxzTjLO-a9hMUj8V!S2iM>%gX)D>u1#pw69BhLa$Elm9^(` zrlh#ZBx)*>S?Md?#Bwlpo>O>fW|2Y*K6!>pmheQoM{fDzU5kbY^wv)rg~gSqYD*Q5 zYrjg^VG8raKm+;+eI&P-WaU^O5(2}aWh_lB19of$V-tbT&aWgnwXSyAwp~ZJVZ%-gT!q@5{)Vg z2)_}Q41D@_Qg@tUE6&-3kyw+4uuQ(>Wlj(c1l_OKv#g_mAH?5Pdr-heAJI6z4o}Nz zrS?PJvG~;MPL_X=!o!llWk%s4VUz&LSU7(!%jKtRxv6@P>tR%JSyJ!Ys>h_ReD26> zN}i`2$y!}&z{R0 zFY&(>=BD$JM>q#eBXn%MPn`9U`S7J%?s4EkvW=}*oe}R^##JA}k&jBzFJrn@p5 zyZB!FE}Jsa>)BYAHAx|H;0>-*4e!r^}4)ZbhZ+}pu(=jtN?cy!HB>utjqG!%WZJHehnzGc*94$(#bP;HV8$Ocaf?F8WL-K)Hd#H0()Dz_I6^KyEwXpTX3VGBpV{D%9?+Lv-R$Z&8@h?wXxwoY7bv`Uso%%uh=-W@EY&nWnnBEypc`4s(wZqwT!D` zZcgkOzh>>BX7R)t9!g1oD;D}?LZ!${k240DOyqx05!PDT?#Z zN}7s~@=pN{Y?v?ijE_6x=5CcBqf+Y}8DLeAsCDJZwKz5zHf}P@siNpal*vK0ezO)G z9TkLm1F7Q#k41(0H@0|mR=p4}bMr%IuxOVtmfvohveAi7&LJgCXGvDhS2dh5Dug|X z%t|;g2!ze^%m)sc9Me84kV4>e^%LnDL@9jj;2~)Pg$}1hlTtf4 z)Ka;(5kG}IdBC~BcfhC@sfA)D1)1~tWjIm@z>14f#P4U{>5`)s9&6lKxIAEA_&)a+De|8B58LO6Tr6Z6cRncrF@wF(!Onz91 za>6{=C<^siT3&O1}iMZjBDLv3KNJHjPBw8cC->rI1D0w%By@r7}Ty5 zEgH3HjmXvR*a@~Q(XC^rZ%B7BG}2R=xcuCo{Kxl?(SY{jqmnGoxWOsI*C+UQa%|Ml zg1?&YrS+3$x-pyhhQxLG54s4JIEvd!FvqN=51Q+CJ2bmU(2t9!S7&@p)ZhM6Np|qp^>VutYJ2=b`bIe!B-r{%Vq+C z3m7hdC`P|DK5Bpn!$J#KnnKbD>3x}&MITgie`lEeoUvQgj0g`UXHti5GS$v;s^Q!k zQbkPL_*{$6Knu?Kep|Fd4L%yu%~T^1>TU{MeG zZ{s#beLIAvy=jLs=V1qrHC@*wln@z*anC1Q?iNYx{-ekcI->7$7<}CIUh5prZ^;;G z8PDd>M>CHMRe>e(-=acDAwWh*k>!C-ZPpqH$c|0$m=<*vKuaDNw>k`X<<{+GQiOyBrUY z^O1mII}pX)&9cqGu%Eh=tIB4Fa<-vV2$h`e28ySrAMSV@KmA?u6l!CbdBE%BgL}0R z1){wS*$xw$evM{DO3V8({=jTYhhvQ~?vPv~r5awZPl2gJLh;4>m_*Mf+yK|?c2}(m79p1iqz?pM7 zMKi2e9fKD93D|EjruLx#e61V;x}VPR20pgXpax92GiQIcUh?VvZ!Dg)H^b1+Jifn? z{csJ7S4zgLrR$FV$GYBmW%1*?;>~wU&Kpd0uN|iU$4NAZvE|>b={ttTj;%$O=_4zEgKSrWQ978j9NbP5;*MisD-XLLkChEopT+v zfxi8h%XEsJiOZ=u+w-JP4&d=pB4Cq(^=GXx9Ho!+LBpiJJ$!t^%E8rfUd+z z>V4x@Oo$A2{2s7+&aRlBe|BLQn$8u4gQ*|CtXgRD(q|&&>ax&pCrf7{ABiE+<5rn) zI2SQ<3fPp}u4RppVpEi1R9?`NH0@+j@HR_|)r3d$5MRefg#=)j&Sr|Wmnz3D=Pw|F z9+IfChk)}hmT9ulkdIkUWXhS4K%h!PRw~Kf%0=2OMlu{7VR+ujiN23eMn12xVx`%p zglLy+1n_cCFf^6TFWIBpSdxdWQ$?nW2IUSeMrrg~{LWCbaBhdtXPCJ|#+7^_TO#t; zC{*A0`x)TgW^4eSq)XgrriA9@A?BiAqs*mTiQlW(g99}$nT1CwPQ}`#hq&FcB1U^d zQ0JteVrjHugGptHjkYNcA;d=UW_-qkM4CySE#xOP#VS?e~3Ki81 z;Puy;P$&_Hxw&j?1N}Y`7FIhyM2LVJaM|a z=+b?tb?kXf=EO=>YfWbGI^R@xcWS`?-OdcS3^*H>k;jxu>-DI=rr0AiVIzrsH9ZwV z#3TJiAu^2)Qz3NKG0{LQFfP@th`=>qw(axC;n`%k=3VBz7XD!u$DZNNc@A|(J{^yn z&nHW=i+zvro4|S+?wZ1)LL(-$z;K6409S^=K8UWB1hgXj-S5|Pe%zVeEyi0|U-SU& ztF%-R{TrptEzs?{DnXX+!YDQQL;Q>H9ulnhC{Ir37bHvEE>a*GuySi=n-lrIODEQwGk8?fkot2?fzSiK2%Yn!Bl zi4nXDh>Y>i=YD5Yq;sB6=Au~ixK^Ni987>lMJt@Mi0UzBy_DaE!3Dj^Cn_e1iXLD> zj}jNyJJ_yjGaeiYsWT#8KQX0(1fFy&4EUc6=nYAjT$Mooz?=LcHQ1kG{ z+Y@O?(Fu~65;vnl1i0y`hBoKVJ@mC@5s$v8J*G$fa&(B_+_Ig9xHx)LkEM%;a~;Gl zC@Ldmc;sdq2v|rQa>8yZ%ah`JM7pox{Zqtg|E&1EaTD=A@Y|KVOF?AQ2j81nj5XWe z8@{5B-%HB=#h&j6$<3C8?+wF++Z^AIus^Ex^V-zj4{DulY=50p{a@oP@{Seem_O*W zdw-)g7eyV+slS)Hg9BBPTQ&HVmQJrZWAI@TLdBO{G+*|mTMIZ7)^??E$KS29fA60^ z<0c9zW(Fl?E4?I2fdnz=J&xU_fy;7Je-gZfPt%xsIjR07e#P?y|4gT+-r^?FshD&MrC7Ego}qO zm*z=4YqbLRLLj9z7$)U@Le=Di#l>>4Izp^|GCQo$zGMpyYrI|e;_d}gnJ5y9ga~6W z;^BJS%&}&o2MdV^RXz84m^!3cP!BhzMLUBK{lwijwooHR?*2Xj3#-vs=3ug+I}!r7 z@k5;LKvQx#c|nM!+BBZ=vta4Smxw12_+hu&Ez3zj-r~oq={uod6c)IU(@Up*_C8@` zrm;2`?=d4oIlmsqn_&Lex9RbUa0N6kFy(t-h@)7#k7wOVywz^Uo}E7!lg=q~-?i_c z)_?R-iVn+WOkUuX+;5GQVRo3m)Tlw9IreCb8yfuUbx{MxmgyOdgo5az)}En6p!k3% zsz17S^r~Z5umph;igre;sPyfvSP%7)6e`qca~a0D;NHDJi^Y zUxj^F5_O6&opOE_Gnb+P8}3AzQMqJxDF2JJn>seeYra%2;1t^X zyn3r70~;hTqdPWrhn?&}Y{~X|2;Md4a(qLox_A|tMWCChP&NWT*;l~Gsb4jXu`B)hkG>&hnrnSpp3j3w|$ zV4tzf-w$T-M zBN4?Jn2Rn%Xb;fiAzxg21`FCu`)L2y-N5&g3bqWsHQ`q;+`J>B!~ZWRllYHhE+eia zN=T^>{#{`}WENAa>G9fRbs#+~+g1gnShVBrN&2~Dg-p*UVP0-=Ag#pC677ZJIr}cj zB0U6m)G;~{O&frt2c?2+j7CJa|~_N4}3U!f)A(`V$)#fQ02U+ZeDz> zIo_MECjlTXx&x@Fl+4+)-H5j&Y0u*f-NcNIq)wD#2EvY>oB}v%6LgdMmi;AsKLA&^ z)GfyE_QyuVpMQ^S{DJHYbP+u1+B0%$h5#8nUloObqSr`y^_sg<{C#0-5Fet9dm64$hxUqpHh=Vi`?v&Xpm zz$n;%i<4K#3}>j&s3E=;LuVm_D?;4kHBuDpnc|J}9+1ez=cW?L6e?=YwF>3fzg-qm zD)a_lN)lJ#s%_dewrA`by$W5VLTosU_>K?URIZR~(Xm9cQsbQhkZV2{D+r&Eq~k@D z=S05VvB8zR8e^I>JI~JpFMbnuC$toh-m7Xi_&)V5lg}xjZ4{)A1cJ4b{#!la;;#ip z*Yax>DibTHDC7BxjLXe;y}O!t3gCM^VL>Q)HPrNF-uzi}3>^TVPPV(=+k-^-OSV`x zyptEi&*Db*l`lHz1Ev##KTCF&d}zZhs>6R+C<(~N3Q=mqX`W3QSc`Y$4LD9be#pp* z2UwE^FkR)6TF$N9eX&n{$M^P3gb5{8v?AWy>!I^h)6`-OZ_)R_#cHQGquYIRZ@umqkPE96cJUU(>ULbkxB!b1Hl z3EzR_6wp{THchA+vrY}=w3wjudmF1uGQbO*!^3uU={PQ>x-7h6=bwK`n)H!%B|T;4 z(MQ_EVFP`Ve4LB7>Kl{!-qT3BcxahvaiNCihpt}%g%g`ZI7Aj+ZN>9{7JrWkYev4| zMJx&b*!PT>m>>i+MG#?JCw6haExoZ&%;kd*)>((21^g^I{3Y2?1b1OMf$*dSOmJ=WMRa2Kno6?7v#8bD_y&QaX`)O{+{DK?_I88G zT{QD6x>6yH@{E`yx6D2VfWtGXAnZyB1TKZU396H^OOu~P3vwmWce|BQ&eIvkw1e=H zL~uj%b|b5ab8x3|Q#u#IwjfOmvE)l3Sz&e&IR|EeVV5({{&HHaCrtFqb_EYDE`zfw zbvs!NF*krbrYL2Q@$3(&0n|8vSRU#TYwFO1gP7Q`LZp<2#7;u=7bL}; zm6d}5jml;Fe8kgu;mTamz9{==^K@Fg@dZidBcszhs{LWwwU_N9I>I?|0HTIrq4OF^ zGHh`UvZXQe?s3#&iMVR9_9$F{31KanBhW9JST>vm64sankb~~@o1FqG@us!JXlz%R zgJK@1ylKrq%aorj-4f~N$oa30pS@%5ztcMfbp5rs3(~D%xk%}P*(U#lBQ=XNFMLb; z8P~Aq2U!1O1LcA9Vz(Mg8rcA!{Zu1}D<51Z=*K*GJl~?CFLK{v-(e`baXrV#{@6fW z3-mBDumd;rMLLUJPiOk>cO9Yf49#rQLbCK9*E%tkbyLa%E?#?2a{X?rj96*;bURs+084>jZV(nUD zb23Jdm^=|FfKBw}P$=DPYHNaZZDL@e*R!LMVmxZXIm4%n4ab^J%_L!r{F8)Y1kd$N zTB+mN%5D-w>tTrcuKToECv8%r=aKCsT8UgYi>^+UbV)Zx0V+&15=G&y)vYd zsvEw8K({v^zH~7q;nZsblpWNuR$S9$2x%AnLafOkWdd9tGB|4V#2RhC7xxV7XJMKERxo|KTv}GOFNnD-SqFH@BIhLW3QI;EQpdq6CZdyN z4wm5XScL`#wcky{GJ(ftK}yP$>3C@aHwu+&?oqN`BQnl>>cjjzMV^-pA0N9E$ycc8 z^)z_bg`cKCTbYwmJE+CCG=G@h*O9k0bctGKRNF=gt7Z1!qFg5S^OfMxfP5c{X#PNz z>-5TCmUNs>UN;)v;zhqYL|jlVQ&L|OKW9B2O5atQq?Sg6cz?xn3>_j)Y(kcX@jC_N z-D;n|ZfGA+QAJKV46uDv_(|X*mmgwzphTeZ8L=xTZ|CenfYba4SxCC!bP*vj1>ssf z6}2&P@NGtvI3rH7u@EyQbu7->AZe=6D$bLe!4C-QJfAf{^YFbkCJ7NKi&qWe9le*w zXu9kvI)!ONCux;SFoe}k(+e6w`xy$)WW=9ANveqe7ve@uKk%zXKBVEEvGsYqECX#& zsuaS~tyYQ?JtkJXxcO{FpGO=O{DMfqc$|;$9m*F{9(pL>HuC($?h1|>%HUG8{d;jv zMh9`Bacj5!D%acd)fch+=bJeqTmV{Pk_KF~GIr+!A2iS@ueWgG%rEB9#It6&Uga!l zWfZb;n(`(FPylbry_G3d_vL;2HZK)x+DcevaFVe?N7^QE(W~-qU+;sQY$2N{c6wm5 zvLE2;L_Pw~KE0P!%jCMRG>MPBcg$W?bXS>um7a07olxWsE-hVXXz$reFcMAp*y$vf zJbd&iv^FIkL^3dF6vZhoQu5ynANV3~;NkDxN6o}teEwmm1gpc7q5W&e@^ zEJY2FWdd+8q$lNnbkeZKw+SMt0V=yXDWnuZhVL#OG~*Et67ysyN*S6sQERAQcLK1e z0SExZx-Lv^ILUcGQ(kF{;;aJzArAtW#GGSAPpn_>wQSt>muyrtDK3Kg+hq&g|^W z{%6nG^UbL`@723i_f^%cTle02uYyIb$VAFY&oa$&7hFcZ;b*}26sGa#og#b>2h(V%!+9Kb9$Efta5a4@#f1=3m z0&*Oq_-Jlq+HF}kq;voWG3H5!S3-Hh1oK---VfD5dg>v127z-CPOaIM)PrW;4qPF?B&Q@=LiC| z6I=SddLdT5X`T5hDkJRa=WbEC+O5w&AkkRSH+fZN8?9oo)wMBbZrA}&1YPS^3pJT`7uKOtpYe0XJQ8oQet3;#9DE0%dVuPzl14K1$e;_t&+p{mdGv zToHAEYKp*%a7ewy`!0cm4N0et`kdKdBz0N4>$$~EtLJy$N6Im(X_lK`88dc4kgMdC zncLL73GG|1KD>KGal-?J$zegre9@5oJ{lUKI#0UXW3_c0ZXQhAPS=<`L!PkB7VpYk zJp>gUJ_@1UOBgmD1s67BMu=pkGcgf_!LWRiEH-NoBz^Cdj(lvT7PuVaxc|*jDn}a#wAD;ob!dO@sg5!XZz0t@>ua zn=>#_JE<9vsa(=%ugI0j?Bp_CTj6Vv0^=kRC(Cx9!B^34j7?NzPBOEo)~j)ZNNq~J z+)44~IP#FF@qVS&+O5Y&u^*y0kxrW=3F?SM&xbXZXb8#Jq75P7&idz48iweWA?zYf z##dIU=5U3c*oTk;(KW%N2=K6vX-C$O=TsF?&N&3>;4^Za(i&VVyAc(ty0#}z4c3xD zzP5-j@Ev~R*O;J9Cf$qnS%3vB-9t{nPUfD{tRyo`Lz{(Q)YSbHfp6({mdD44FM)qy zFFgEBbhkR9ZN^25q|gSh?vD3B2CO(&aRDr#em(7GCes{1gEVVn45nQ{E5&e~7QY+X z%OQRIC=VNR@23m~mjEL>&bHl0U&)trqBlZ;T*hvC@>1q&_GVFaZ6zU7=L~bOAEtDa z8h-fImZzeXc`PT^pSt7*FT{h(caAYMQzpRNMLID?=^~U)#_PhSfO8hFQ(?qWQ;P|) z!xcv!*973Y)GBMLW7)(60Cve*M zj>y9ZmDS>d=ohV7!4}tO{s%y)V~&rVbQgB$SZznn_;UNhj>yN|PGg7yYT3~|bH1Hi z6z>DOf5^F=XYBpn4I_V&E#s0bCFBp_yW77IvYU3OeGoe~b3*AG(9Bl||Jz{aS|Xp5Hy` zYKR>PE{TcyO`l6g$8vp4;gzk5nA*)14@(BO?dRL0zu`vw!4NWC#x8@hV?cC*Y$u^woRK^mcJnid96K8D#N3GDujoi$dJa*fI zqYB!h_YH09UvH?V4Gg$`FYNKW*(uE^Liu5ahAq!~c@zactK7cYuEa=9QTEa&`DP#HWoEjc=}C#@hYwgZ9PODu|cHblCs3q#6!i?o`OOhXrR(dY@L1_teGk&wKdbZT_z)Pdj zmG2~8@fb7={GA5+-e@Cxz!1RrJ5k+(iTCwqj$bf{eXK4i<+EAe*fu=#mhn$L68dFi zzv`Bsi;umtT*W#xd08L_42w6w$ojHybiue&J_91ZDDW4aa6i|-zt4NW`#w?mNN;yh zRxRLE9SROGA>xsn5Em0or%)7$kj#i{wpnMjP1_|dOOT;86(WIG{ z*B#b9pi6YO0Cw(+w^*=Qrr93-g*;#e2=G>CE;U4S`q z9gb#_PTw7gezyp3c6Y0nJZ#gzMUx*-1BYtS4jJFSy1SsY*GC4Rtt`JS98Qp>-LS9SZ$`O-_Zdu}^bt0#T6_NcWFrG0uD8?Lvo3joih z96Zq5#W_8hyu-Jy1diB(Dgw#<`l@0GvoMr+G^4p-t9-8CyiR`DstF?%Z}R+nBge{> z)+#Xf{dR6%GE49+CnxvPMUU}3Y3?rYeT~Pd0WDln1rzhsL5lfT+qd*5zZ&c;+55Ms zj0x{G)#howL)s2RDMl`S9n^fijBv!ee<$bdp~w~$6(KE^lw;sVjJn1?D)U^Hh;DBb zO+qgQ)-OHT#1ftb=I1hf3y;eRdH0=(kLOL>mzp3J!$4H&p0P@jN`jB=_^O(%2g1&k z-D2r9jqTkNMSJH{A=AjiVNtk9S?Q-cs*l5N&@M)MdM&X)(W91nsX_n=q*LhIn6}FU z)w9WcA+Q6Q|$}V*BMoqvjmLsa*9AD7WTgC6)!87eqk~1i{o}Z_lwg*U)%&s(? z+5k=`j8YTnNrl%kl!XZj2EYjMz(&BM;1>}mfz!Vfc0^@=niQ7SEwP9d8`|-!Y}F1@ z&h-a7d>Uh>PyG%UJ;y2k3(-O??rWY@fAG0aW6$)d)Yi9R5wC1$A75p=m+TDU}$m^wP67?$9R zcY{~iOTr}MV4H%doy1LKm+70(uE8zi+qM%xZ_HjKkv;5J{2hTWv)mb*zcA#RyvY2- zeVheCBtk?ZI>~!j;iEy~QbTZCPN!V6I|>+AR(S(cK&2!@{hi_QBe2Ouf6dnXbm>Ox zEfGG5A(Fej+SUI#skK>>IdSbs^baD77>b39>+tTOwsg0><+>d5%yH+d+4W6(Xc>@R zITrkDXH{Rr_MPXe^Or!|qCfYL4^*B*2zJhO+m}_M2|!fMYaN|B+C}n9+`SPa>2n5j zxtGBe@s?;M>Pim}i#UovmFAHRyY5a^@*2O(S9`PeCNu9I*yZZUypkZNa4u7WrLsKS zHD8N!0(&rINBJBYpZ%#PKLcx2gK+vb_i6m1m^SS#PwQSCtDiOXmi;XrzpLSW3)N`k zf_QhNoIqGdJ$<6E_p4eThb_A@yXY3iGT)jjbF}VAj7@h(e~Ahz_wi&Ot$TW0>x=!v zSh4l~5J(U%g(cbONsCTV84NsmTPWF6ecEIvhYV01+A|4{R&cO-&*1foTvve8XA_KT zzSE^q@xgcQS!&AqxqPpUHeY_XUyc=j7po7>sD9VumzN%W8SQ-4NSn`$e+ZiZ`G?u_e}G(hj(Lvt|JvDa{WO&LqNO3la%XrM{D08fH1FRhv&zyjN?$2qXV@j= znYl|eU39trE^jPWi3oaCbF80R5XXWh#@I@uI7&b|bmKEIeP+WxkJ-ia6(rBs0F)D* z59aekQcwKaPGJ${=@ForG48$tNj!pJ3&52cU@|JIeQeFZT}%S(ILOuL5BD`q@J55h za%VBvZYa4<+j5lat<8LGnw(Iy;`bNQFzk90K&6*O=V#ni>}pXXGJ#%+3DQ>O%S!ww z0PZFe?rW7(z&agerj|LS8s_9em_9{a2#fw7e&i~*38V}O8g$r3DpFLw-2s#Vc!PVgS>r{oXOXkTdQ9#Ck4^H7U^`b*TMkp zxo+y_k}Fd68@L|2?Be7YU;JF2=HqR73)+NWHJ&NdVm&Spv?!}0PH5vY>t|ly0JWUF z`!3^z)NZ|&`vzbB&&0h_YHX@r-OP87v{sB*0F1T~zZvjSL}3`MB5)L?@RFZ1ME&7wf9>prrqk{lc2d z@*OXGHG|&C)PIr+$%iwsHPW`#`?w!ocNtx+Q!XiE*54yNlNSj+IzZWy5jkbnxg&B2 zws=HwJQ6VLdApBA8fq$?yNWVG#WbX=`1Mz5nVIwD@B66Ta&D$wnOK@5ly{>kJejBi za4#pV016M-7dSW;S%Y>*?lAoKr#?`oT7;0l&9`7qHqPgbg+w5B2Zc~3qtHF@CEpTKg0EGQUc`!H=wT(6$5(i*@+7@UQ^*p^_?0++vW}RqEzJB^a%7+F{|h;aZ+vaXzfed20i_K(wb?&iR7yk8 zRd@cCqWYVtZS?nNN%fH6b-g&dVE{$g_|ZS+ac_A9mh z>7t6)^wL!i`~@ce%f$H`@r&gAJ^xo(?r*C8E=eh*{+p`5$8UcV=U-6fKPL|JpBBhU z114&-7yg3oz+QYUUh_?oH-F>zf8*K)Pz-q_4i2=y`6=r4w=Cdq2HUT${=HnXo#L{+ z!l+KJHAb~r3hwXAwBL=optk;Rl<}XFP&6`S0=*at071cE01AVFzNMi|pm$2-U6awk znS~Bvq)my{zNFl$mhWe0AwOMW^JUnboYs1xx|xyun)hRHlV=yp5Es|%I*F_#j7(65 zCqS+gu_~#NftQ)Cs5Eii#S5{hBt}eoQr|Y993uX%`uzCrdbSr=)_}wDoo(9*4m-JQ znCg~phW3k!44)Wv3>htga7t6QnMyCjqcBnvg3`LX{nhyUQ<8V+7#)rGru70!K@5zr zXxIh*K9Ps7EUrB+cD!YL*G~|(e3sU)ll^3d9zuG z+D)&fV8YzHRKfDHs8On5Px5~XNU`c3*rsbCozMUn?z%+*2LQn7Q`S!_c0ga2>_Dj? z0AXtPmE>zky@fRz0kgHN}-0N*gF;INS>a#O%|2zJX zRWy^f%1bA|4wuIl+?ELLiozk8baHyfao~H4J!>jF@BBu}#zAU2a{yke zv`J`_|B3|rK5MVkL=ZSCdMcFvdP-q;yawHuNab0ofs9GDm5X;LTR07}7%XtKi?$-t%LGu`&zy}hgYk|UD(kaWVGMp8z!P9N*M zO(YZ=m2vU;S&5lw7tW%>sxAvDd#`dh7>Z}!V8-RBz&WYhXvISsZaWFi3E*Rxn9iTf zpzKE<6?G*Fo}&Fw+x?~OW`Va8zFL^&VW>IMUVJ`LR9xm`@yO*#?S3G_SEF-12{NJ+ zHytx)e@1A-Q?%viG_}hA9=1eWp=hDD_U)Wz-9HEKsA2Nva++<;ud6ee6E4>Dp0reA zlu3X`VPF6{M^pXurK0r;s(^eEnI=s^;zdIbi`LL`DuVDHIXYsbPY z0MBHPl@U2CV3~U8WRz9^HeX(%uU|Xe)H!Qjntfdwz!klNBEv_il;DxXnR8L_cmcWD z8pCjPb0PC)xB<9mI*c9RYjIvG60bh1Yqg9A6^8}T&7GsjErED#9@ zcfFBPn^*X7i3Smm-fgOV`Q{5fmA9s{)x5@DD6@Pt z6Y^Gx26InCu-PD^X#6J~(1s^Vq62H6L%{Nm*V5 zErBn9Ooow|5qW_*Z6qc{{#m&mH@wZzw1vYZ#C=py$I{51^ohB<3@KKkVBw*FC8LwX zgwt&ik}=^-jxjV$OoZafRzrLaPz6}Ve5hc)vs84QBG#&SUdA8}lon=Uon9LVQ`3s! z09M9TZ&iZ!Tun29m+$<3$`Ygh`tqVL%bE7I{j{iQh$}8y@#xdoDe-9ZH(k6J^Az*psdl-n-fLT zG9^QF^tvW7m;^GRN6wQa?38UeGT!xZ-LgZAn?5|~BA=8-Mp{J%Ca2cdU?2I7Ii0^B zg0jHrSr4z5r-ZY#;EQ7nx3{(f67!>WbzYmsD&it|NvV?6Mu9764AMb78=bCVaTgsx(0-1m0M5j{$2P3o*(7jnvtaZYm>2lG~ydW>tN;N*|FZyI*L@ z#j16#A(sz;t@(KY!{)?3atE%H6#=6kLNtJ%CUfsqS&P3Sz`sYZDF$TjfIDGESLE(E z#LVd+dOjkfR<539#&l)aodg^%p;5gpI+^3xQ{$^C*@deWsB6?FLG54t0 zavv-542xn=<<#(IjWF4$Sk2onvJ&T>xmO%U zEhK!D(N#*L)w{+%%%73ttnQ7$M{D6;A5KnkB@m_*X-%P7Hqd)anY+ZF7U)O_6P#~k zqWA9zrhT&DYsN1Q&I0Q2i>dHvPeI+m7{)Yc z>Z3fc3i$_{6X>-i%>=@;>$YWt#3%hzJm2s02Pf0|H!2O$|0Es9@&s!yE)whbXl#O3aMqq$XKihmy$8fpv%08kS7OC* z<-G2`Ur5#w`lztDxEGf8o$HNfCYxS523@;t^Q8RM=v&VZb!R5S!3am{g0UmZ{f%}v z%N5?ZKdpE)diiASG3&oUe@KZEV=Zj4T*Vi9UmMOP(ozog;kssW{o1cwzMco9jF&rM zP{YoxnYdfR@$j#mF5j^46ZmUgzV)>U!pk3H3EhH)Z5oY|AlOIt3;Ge4>{NFRqh#B` zxpS{Ryl(nyzD52$q2BTN4iH)}m0fwJ@ZWb@)7TNsz#SXUEicr-y`bgI((#hdK%q!c zM0ARA)%vxaGf%kva|_EjjvY7E17UTCghQK$Dh&zx&8)HyswM}Wl5=Vh1Q%O+_tRz( zo~D4N2(}lr+!8K=Aew5ZQafQ{-Msl^11kMrxHe&0J?#L28<6{_H~ z%X(Vp1?2=Yyi{W5uyu(NxafTtJhSxzt|%s`ZjQGgRN63MkbYR$2bN+6n?(u-KUeBz zyN9v0<}D5jfx~#+fy?biCBlqa;lda_5l^WDsgga3T%l}L@#lzYXFND9W2UcZFfr_l zqM6}PA)4wWIboGqF5PDi#)OMvCJP4luTRl?8>GrbHd&tq<@M13eI#?NK;qS&x~>O$ z!c4{~AEje=A@~-M>BASTo^>f}<@-u?3`eu9nc<&d>Ip1|1WTfTyQQ%QBm_8vc^8aS zh90@x(GH_o2HjD!y%e8r3KwiPL+Izfm&xl=pf);l*u+tq${YflQ1%yLEnA7XC#>O0 zaI(!8Az`e`h+M@|n7o~E@8&*exPfq+TuOBBkX2)MZEfz|$W!h!(u63%i)`|Frp+LVw=GghBR_(rv z`4uuz^H)b##`v$Ce>u8jQBBz(LEpkiPewRPV8QX@hX%C;Edxv0r}uN1Vw$OQEO&TW z0?}HywHPwFumHRNlFA&Pji#{UEvqgmdtADrV28oPefINE)}z9_c)i=qjl!Cdm1YX_ ztePNMHrW&Kk90#03Wdf%UT^aglw_gwcKw_o=9=d1h^Mhna|7f`N!~q72aoAJz07+= zLyVYqtRNK9P-#jA^E1h|@9|4*FO41JjY?>`(;#viL5GGSJ&Z)P2KgJ1tdfWma#wpU zx)I{x-fh@B#?GemZy&zuqs{1D90XHA4rQ?&vu)!zl8o}L&2Em|6J#qd`pnpe9qs<9C`W+GirM`B`PtTt(~`FrIAYo$ysfwK zxst4{#>ExGE9o!KvEpLu+?C8!V^B_(ykbv=Go%U8m-uRd`&!7Am;YB?X^M;`QqVAOZ7wbImJ^D5s9aoH(oo>Gyk9^AM zF|&;ygOc$?t;H-6RHS9ZYe(;=O%si0YEOP}f3wy9;^pOsj}))nI7R^{muy_Ttv7O_ zZQ|SWkI5di_iOV%ZOX~j@qwyPC&&NiUwWphHh48Qt(yw2m5MFvzfbxpuqcqHD*um? zWAM4#4F z{u`I2%+T~p{}3z>7cKUbFH~7C%n!zIqS&9Nl1^KL)@-Reh&(L65ft=J7&Nrc=}x{b z8|5qi8L)5OD+atP=uhHA#QsAAzStDGG`jH;;eVDMr3$aXR|Fp-%ZoHm@z~fm^(Z^% z2Pd`f$=7EV$C|Q6;g`J&-DKjOVqwfBZQJes))z?a{=a$==ZZf~-Z46iLQ-M~n1acDKW9g=@a2#Q`IvUqtQEpVE8!TG&Z&QaYngZcNJ&bz3N0?PAB}n zQfwsioOPk*I5GVW>bqS1srnknKw~c``w$7s5F>Y@PCansPnuFJIJXbeLFReS`}#on z#5a>>u;`G@!0}+G{`7L8hR?uy$d`SjKLaz><=~K(ievNfRT0sQ?{`9~X3ppy__?Qj zqHzyAy#+6vQ*k(bw*aaq@EIUy{}}rLR}`8{u;sAwKAFoh7F)$+EQ&o5oIIma=(r+k z8LCPp6wJf7sOOP_3xsLRBb)$!d+kL2*WsjBXd}BdXXWzmou+Sjdn?qit{Fb>0K0y@ z{axRh;3Zx%KEB4wGwrc%<_mreJ|lw!CB$5u0F;oXJqGMD&5&pjbZ%>PKQ{Ag;!leC zuX_x5vS%Knw6qtnT>+V|V$Ar%py32>NpxM%HTP<+kB5%l)x4DDsGcxwFG$0G`)i@A(t_R7+#Q0hi)P*J9;v1Oq{sHifOrgE$p8%@fshsBSKSc$3nE2 ze^mfqAO|(Xua9ZSzLJk2!+-Wrpw-pbp5=RwrSQUACMr(sejYg#m_h4D)ApaAOyB9r zBeOzMe|W*RZWpspc*hWSqvZNob*O0=LzWeXjA#xEKyW?cf8{xv>Jz<=g6G|rTA2;G z&q)!uf?Lj877zS}VokxY%X&=VnKI)F%uF-kt_j7$`BExon-|T2Iq( z1buKDP_?1LgYA<>Z0_CkdmH-eKGQ=JGZBzW1X<;N*ayprBC*?h*{_~)t~```gS58F zn8GQH@jNFOGMN?7dkSPo?&_9l%|<0E(%hn}s`6KW>5tREz_wf(sReO8$b`P?XkKeV ze&3uT#*&p~(LgZ9L-6q>WKu$_qG<<-y<=54t)VcRPFMa%mWn2v(MWvvNeB$(E~Re# zF|H{Oe#a;G!aI+kiVrMToNHIC&%_K=+ii*M?Km|48=$MfrEGq;ZE9`f&+fVDm}_|d zT6W)NlVfBdck-&|;kIb~bIcq_B4v{@_l13Jq+Xp+Dr%N$8$BE;g^ocoGR7&{)z=nA zWE(s@KFBEsknV=}jhDSV*#G5I-jlNtdsLcE=LI<_vs=T)VLO)ky-s`}UX-!hzTF~v zH7`^GLtvApi+dWeJoX|V3H}R%B{20Qnd>KLazX`=TY25a8OtDUE<aL`QW^gup=Bch&|I8eqA{fCa9Iy%Sh0l8BurXc~IR&@a<2unOokKz*e! zlyjNJfH(|#K`O->Jnaa(c;5pb=d%#(`Cfr;!6Jg*dHNgC zMm@&Fc&2g9P+DlUICJsrue%|UX57Bxicc;*wXSFNzL@qMG3Iv_CNqB+OejP|fody! zev~!WVd4_boeic`k>!oHSNfep z5bL~4f~r*edh@}BSW~eG9V4MB%Fib*^=LssXMF|JDcJ-J3kSYDwWN4{o7L~IB|I*b z*7Tg_oZPiQ6P-M`ZXxs}6~g)=lxpRcnPzhMW02@v9dE9}F0)-1{z*2a@X`Wh4^z&R zZx7{roV&axpvE5_3^R7;NVx&rxn7kVV^ISN*6_(oIf%;#f~8x%WlZTl9;Bi#*t~2) z71-}0bys6Nguja{UKkJ3s{=;Aa z|3jLP2S18?VvWAx%lCL_(lO5;I@zfQ1B~s$`cyS{9Jpq(dh*W|+iLqrgg2NqryssS z&x9RkJ7Oe3R|+wblwz+Ab}xuK+1$#OgOI3P9K@xYCJ7PiH62p&XG48p&VjMlY)d`) zZYkRvde!DVxOKI#vl(vi5h5D2GiBK;raf!Pk&S|#C>CU8& za0=@r=%0Ad?!hmeAkfT~UhTPqaDHxlaWUV!li;8~=5(wlE8&519#s4(FSnfax?~e9 zOz8YN-rP=l$R|{|r+>TXVd?IyP~mR2M`L;B3C{&_)P;fft>T~mNO-?=apwEa3RTIb zf#yF-%MXWcE^Ymw@wWz!J@~P8US>Zl{y~wweWZTZ%zNw8 z-k-+7-|V%fiI?xs{9u!vseQEh;RoUUcX7Ucg!^i-{!N`P-%$QXc*hxcn+Ba|>^@3# zWC=ZO4O_Ei>-fW<_Tzx`D<6LBs?M5@IM=-)gysb561|r{#--rY6Kq-8d8`>+EinD~!5N{tfz1C{b#xf#sL~I&hA9KL6qa*BFCg`ix&wO4-vn z&l2s91_`f}`){mH1`utA^V9{aIL4H(*YlwjQm!q$99;g`ma zYXnk(JkM|#+Q%ZG^+l;1w=kG;x9jb?Th|C^nz`Dec;9LCuCrQ43nI61!muaGGA_Yv z+xklI@DUX4A^kTuRtj5ro~!S*zBZ=E?EXZgdJ}A%prblg5XOjyUY8}#KzYxN@EO?L z!OKN)ViNQ-J(v4Llygj$)4{Ou7Pdyy{rbM6EN+RKhF%lp_o?ga>&;a%MlZ5un0h%K zGU!x@CVLnZs59wTF(^Vs_?sZEhNd)zYGuxR9G(w`csZ9&qGCq0#fdT}Je%c=(0LrC zibnWF!zJD03r_qPD@*7TBT*Ua(>R)jFtsF-;+{{?uDUh|fZc8oQ*N`$w;p9iyCkD!c%Ir3C%9kZxp~`Qiu^3P zDh|O}Cw`LuoRVJP0>9pN}Q z7<8lxivTaILtqT8t{5a9iExk;y#dxm-2%bDln-*8{kU{(TO_$PK+I~rTC>+%JVkab q7!*|Djek5y*?@cXzdfDrOMX17PTwXGzIX%XWg&zsqVlIdPy9b~i@>Y^ literal 0 HcmV?d00001 From e503a09b58975a72bdb88201b1b127e5e4904b38 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 22 Apr 2026 10:26:24 -0500 Subject: [PATCH 02/12] Wrap up part 1 --- .../client-godot/STDBSimpleConnection.cs | 29 ----- .../client-godot/STDBSimpleConnection.cs.uid | 1 - .../client-godot/STDBUpdateManager.cs | 109 ++++++++++++++++++ .../client-godot/STDBUpdateManager.cs.uid | 1 + .../SpacetimeDBConnectionManager.cs | 88 -------------- .../SpacetimeDBConnectionManager.cs.uid | 1 - .../client-godot/client-godot.csproj | 11 ++ .../00500-godot-tutorial/00200-part-1.md | 25 ++-- 8 files changed, 139 insertions(+), 126 deletions(-) delete mode 100644 demo/Blackholio/client-godot/STDBSimpleConnection.cs delete mode 100644 demo/Blackholio/client-godot/STDBSimpleConnection.cs.uid create mode 100644 demo/Blackholio/client-godot/STDBUpdateManager.cs create mode 100644 demo/Blackholio/client-godot/STDBUpdateManager.cs.uid delete mode 100644 demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs delete mode 100644 demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs.uid create mode 100644 demo/Blackholio/client-godot/client-godot.csproj diff --git a/demo/Blackholio/client-godot/STDBSimpleConnection.cs b/demo/Blackholio/client-godot/STDBSimpleConnection.cs deleted file mode 100644 index c4661f6f255..00000000000 --- a/demo/Blackholio/client-godot/STDBSimpleConnection.cs +++ /dev/null @@ -1,29 +0,0 @@ -#if GODOT -using Godot; - -namespace SpaceTimeDB -{ - [GlobalClass] - public partial class STDBSimpleConnection : SpacetimeDBConnectionManager - { - [Export] - public string Host { get; set; } = "http://localhost:3000"; - [Export] - public string DatabaseName { get; set; } = "quickstart-chat-t8oj3"; - [Export] - public string AuthTokenKey { get; set; } = ".spacetime_csharp_quickstart"; - [Export] - public bool ConnectOnReady { get; set; } = true; - - public override void _Ready() - { - if (ConnectOnReady) - { - ConnectToDatabase(); - } - } - - public void ConnectToDatabase() => ConnectToDatabase(Host, DatabaseName, AuthTokenKey); - } -} -#endif diff --git a/demo/Blackholio/client-godot/STDBSimpleConnection.cs.uid b/demo/Blackholio/client-godot/STDBSimpleConnection.cs.uid deleted file mode 100644 index e191f6113e1..00000000000 --- a/demo/Blackholio/client-godot/STDBSimpleConnection.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b4dl7mul5iev2 diff --git a/demo/Blackholio/client-godot/STDBUpdateManager.cs b/demo/Blackholio/client-godot/STDBUpdateManager.cs new file mode 100644 index 00000000000..fde9ee42eb9 --- /dev/null +++ b/demo/Blackholio/client-godot/STDBUpdateManager.cs @@ -0,0 +1,109 @@ +#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); + 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 diff --git a/demo/Blackholio/client-godot/STDBUpdateManager.cs.uid b/demo/Blackholio/client-godot/STDBUpdateManager.cs.uid new file mode 100644 index 00000000000..1f5786b30da --- /dev/null +++ b/demo/Blackholio/client-godot/STDBUpdateManager.cs.uid @@ -0,0 +1 @@ +uid://wdjx7u1fmhid diff --git a/demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs b/demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs deleted file mode 100644 index 33c19c66b63..00000000000 --- a/demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Godot; -using SpacetimeDB; -using System; - -[GlobalClass] -public partial class SpacetimeDBConnectionManager : Node -{ - // [Signal] - // public delegate void OnConnectedEventHandler(SpacetimeDBConnectionManager conn); - // public event Action OnConnectedTyped; - // [Signal] - // public delegate void OnConnectionErrorEventHandler(string message); - // public event Action OnConnectionErrorTyped; - // [Signal] - // public delegate void OnDisconnectedEventHandler(string message); - // public event Action OnDisconnectedTyped; - // - // public IDbConnection Connection { get; private set; } - // public bool IsActive => Connection?.IsActive == true; - // private string _authToken; - // public string AuthToken - // { - // get => Connection != null ? _authToken : null; - // private set => _authToken = value; - // } - // public Identity? Identity => Connection?.Identity; - // - // public override void _ExitTree() - // { - // DisconnectFromDatabase(); - // } - // - public void ConnectToDatabase(string host, string databaseName, string authTokenKey) - { - // if (Connection?.IsActive == true) - // { - // GD.PrintErr("SpacetimeDB connection is already active."); - // return; - // } - // - // SpacetimeDB.AuthToken.Init(authTokenKey); // Not sure about this - // - // GD.Print($"Connecting to SpacetimeDB at {host} / {databaseName}"); - // - // Connection = DbConnection.Builder() - // .WithUri(host) - // .WithDatabaseName(databaseName) - // .WithToken(SpacetimeDB.AuthToken.Token) - // .OnConnect(OnConnect) - // .OnConnectError(OnConnectError) - // .OnDisconnect(OnDisconnect) - // .Build(); - } - // - // public void DisconnectFromDatabase() - // { - // if (Connection?.IsActive == true) - // { - // Connection.Disconnect(); - // } - // } - // - // public override void _Process(double delta) - // { - // Connection?.FrameTick(); - // } - // - // private void OnConnect(DbConnection conn, Identity identity, string authToken) - // { - // SpacetimeDB.AuthToken.SaveToken(authToken); - // - // OnConnectedTyped?.Invoke(conn, identity, authToken); - // EmitSignal(SignalName.OnConnected, identity.ToString()); - // } - // - // private void OnConnectError(Exception exception) - // { - // OnConnectionErrorTyped?.Invoke(exception); - // EmitSignal(SignalName.OnConnectionError, exception.ToString()); - // } - // - // private void OnDisconnect(DbConnection conn, Exception exception) - // { - // OnDisconnectedTyped?.Invoke(conn, exception); - // EmitSignal(SignalName.OnDisconnected, exception?.ToString() ?? string.Empty); - // Connection = null; - // } -} diff --git a/demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs.uid b/demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs.uid deleted file mode 100644 index aa60874a913..00000000000 --- a/demo/Blackholio/client-godot/SpacetimeDBConnectionManager.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cm87rhifcwna1 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/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 index af482dc60bd..9dc516c0ab6 100644 --- 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 @@ -76,16 +76,27 @@ You will see a warning saying `This inspector might be out of date. Please build - Press `F5` or (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. -### Add the SpacetimeDB Connection Manager +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! -The `STDBUpdateManager` is a simple script which hooks into the Godot `Update` loop in order to drive the sending and processing of messages between your client and SpacetimeDB. You can mnaually add one as a global autoload but it's not necessary since one will be automatically created when it's needed. You don't have to interact with this script, but it must be present on a single GameObject which is in the scene in order for it to facilitate the processing of messages. +### Create the Server Module -Add a child node to our Main Scene. Select `STDBConnectionManager` as the Node type. +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. -When you build a new connection to SpacetimeDB, that connection will be added to and managed by the `STDBUpdateManager` automatically. -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. + + + + + + + + +### Add the SpacetimeDB Connection Manager + +You can now use the C# SDK, create new connections and manage them the best way that suits your needs. + +We provide `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. + +You can add a Global `STDBUpdateManager` Autoload to your project but it's not necessary since it would be lazy loaded on demand when needed. \ No newline at end of file From 473918b17262329f2781f0689fbf77bfba193442 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 23 Apr 2026 14:46:41 -0500 Subject: [PATCH 03/12] Wrap up demo --- .../client-godot/CameraController.cs | 48 ++ .../client-godot/CameraController.cs.uid | 1 + demo/Blackholio/client-godot/Circle.tscn | 6 + demo/Blackholio/client-godot/Circle2D.cs | 41 ++ demo/Blackholio/client-godot/Circle2D.cs.uid | 1 + .../client-godot/CircleController.cs | 133 ++++ .../client-godot/CircleController.cs.uid | 1 + .../client-godot/EntityController.cs | 50 ++ .../client-godot/EntityController.cs.uid | 1 + demo/Blackholio/client-godot/Extensions.cs | 17 + .../Blackholio/client-godot/Extensions.cs.uid | 1 + demo/Blackholio/client-godot/Food.tscn | 6 + .../Blackholio/client-godot/FoodController.cs | 21 + .../client-godot/FoodController.cs.uid | 1 + demo/Blackholio/client-godot/GameManager.cs | 217 +++++- .../client-godot/GameManager.cs.uid | 2 +- demo/Blackholio/client-godot/Instantiator.cs | 44 ++ .../client-godot/Instantiator.cs.uid | 1 + demo/Blackholio/client-godot/Player.tscn | 6 + .../client-godot/PlayerController.cs | 138 ++++ .../client-godot/PlayerController.cs.uid | 1 + .../client-godot/STDBUpdateManager.cs | 3 +- .../client-godot/STDBUpdateManager.cs.uid | 2 +- demo/Blackholio/client-godot/icon.svg.import | 2 +- demo/Blackholio/client-godot/main.tscn | 20 +- .../module_bindings/Reducers/EnterGame.g.cs | 67 ++ .../Reducers/EnterGame.g.cs.uid | 1 + .../Reducers/UpdatePlayerInput.g.cs | 67 ++ .../Reducers/UpdatePlayerInput.g.cs.uid | 1 + .../module_bindings/SpacetimeDBClient.g.cs | 641 ++++++++++++++++++ .../SpacetimeDBClient.g.cs.uid | 1 + .../module_bindings/Tables/Circle.g.cs | 79 +++ .../module_bindings/Tables/Circle.g.cs.uid | 1 + .../module_bindings/Tables/Config.g.cs | 61 ++ .../module_bindings/Tables/Config.g.cs.uid | 1 + .../module_bindings/Tables/Entity.g.cs | 63 ++ .../module_bindings/Tables/Entity.g.cs.uid | 1 + .../module_bindings/Tables/Food.g.cs | 59 ++ .../module_bindings/Tables/Food.g.cs.uid | 1 + .../module_bindings/Tables/Player.g.cs | 75 ++ .../module_bindings/Tables/Player.g.cs.uid | 1 + .../module_bindings/Types/Circle.g.cs | 47 ++ .../module_bindings/Types/Circle.g.cs.uid | 1 + .../module_bindings/Types/Config.g.cs | 34 + .../module_bindings/Types/Config.g.cs.uid | 1 + .../module_bindings/Types/DbVector2.g.cs | 34 + .../module_bindings/Types/DbVector2.g.cs.uid | 1 + .../module_bindings/Types/Entity.g.cs | 39 ++ .../module_bindings/Types/Entity.g.cs.uid | 1 + .../module_bindings/Types/Food.g.cs | 28 + .../module_bindings/Types/Food.g.cs.uid | 1 + .../Types/MoveAllPlayersTimer.g.cs | 35 + .../Types/MoveAllPlayersTimer.g.cs.uid | 1 + .../module_bindings/Types/Player.g.cs | 39 ++ .../module_bindings/Types/Player.g.cs.uid | 1 + .../module_bindings/Types/SpawnFoodTimer.g.cs | 35 + .../Types/SpawnFoodTimer.g.cs.uid | 1 + 57 files changed, 2174 insertions(+), 9 deletions(-) create mode 100644 demo/Blackholio/client-godot/CameraController.cs create mode 100644 demo/Blackholio/client-godot/CameraController.cs.uid create mode 100644 demo/Blackholio/client-godot/Circle.tscn create mode 100644 demo/Blackholio/client-godot/Circle2D.cs create mode 100644 demo/Blackholio/client-godot/Circle2D.cs.uid create mode 100644 demo/Blackholio/client-godot/CircleController.cs create mode 100644 demo/Blackholio/client-godot/CircleController.cs.uid create mode 100644 demo/Blackholio/client-godot/EntityController.cs create mode 100644 demo/Blackholio/client-godot/EntityController.cs.uid create mode 100644 demo/Blackholio/client-godot/Extensions.cs create mode 100644 demo/Blackholio/client-godot/Extensions.cs.uid create mode 100644 demo/Blackholio/client-godot/Food.tscn create mode 100644 demo/Blackholio/client-godot/FoodController.cs create mode 100644 demo/Blackholio/client-godot/FoodController.cs.uid create mode 100644 demo/Blackholio/client-godot/Instantiator.cs create mode 100644 demo/Blackholio/client-godot/Instantiator.cs.uid create mode 100644 demo/Blackholio/client-godot/Player.tscn create mode 100644 demo/Blackholio/client-godot/PlayerController.cs create mode 100644 demo/Blackholio/client-godot/PlayerController.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/Reducers/EnterGame.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/Reducers/EnterGame.g.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/Reducers/UpdatePlayerInput.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/Reducers/UpdatePlayerInput.g.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/Tables/Circle.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/Tables/Circle.g.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/Tables/Config.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/Tables/Config.g.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/Tables/Entity.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/Tables/Entity.g.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/Tables/Food.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/Tables/Food.g.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/Tables/Player.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/Tables/Player.g.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/Circle.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/Circle.g.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/Config.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/Config.g.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/DbVector2.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/DbVector2.g.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/Entity.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/Entity.g.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/Food.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/Food.g.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/MoveAllPlayersTimer.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/MoveAllPlayersTimer.g.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/Player.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/Player.g.cs.uid create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/SpawnFoodTimer.g.cs create mode 100644 demo/Blackholio/client-godot/module_bindings/Types/SpawnFoodTimer.g.cs.uid diff --git a/demo/Blackholio/client-godot/CameraController.cs b/demo/Blackholio/client-godot/CameraController.cs new file mode 100644 index 00000000000..74e2204aafa --- /dev/null +++ b/demo/Blackholio/client-godot/CameraController.cs @@ -0,0 +1,48 @@ +using Godot; + +public partial class CameraController : Camera2D +{ + public static float WorldSize { get; set; } + + [Export] + public float BaseVisibleRadius { get; set; } = 50.0f; + + [Export] + public float FollowLerpSpeed { get; set; } = 8.0f; + + [Export] + public float ZoomLerpSpeed { get; set; } = 2.0f; + + public override void _Process(double delta) + { + var arenaCenter = new Vector2(WorldSize / 2.0f, WorldSize / 2.0f); + var targetPosition = arenaCenter; + + if (PlayerController.Local != null && GameManager.IsConnected()) + { + var centerOfMass = PlayerController.Local.CenterOfMass(); + if (centerOfMass.HasValue) + { + targetPosition = centerOfMass.Value; + } + } + + 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 float CalculateCameraSize(PlayerController player) + { + return 10.0f + + Mathf.Min(10.0f, player.TotalMass() / 5.0f) + + Mathf.Min(player.NumberOfOwnedCircles - 1, 1) * 30.0f; + } +} diff --git a/demo/Blackholio/client-godot/CameraController.cs.uid b/demo/Blackholio/client-godot/CameraController.cs.uid new file mode 100644 index 00000000000..97f18fc189f --- /dev/null +++ b/demo/Blackholio/client-godot/CameraController.cs.uid @@ -0,0 +1 @@ +uid://b0l7e61svh6b4 diff --git a/demo/Blackholio/client-godot/Circle.tscn b/demo/Blackholio/client-godot/Circle.tscn new file mode 100644 index 00000000000..383433e0e48 --- /dev/null +++ b/demo/Blackholio/client-godot/Circle.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://j74mpcyku2um"] + +[ext_resource type="Script" uid="uid://cd0xlwbeuylpo" path="res://CircleController.cs" id="1_ortn3"] + +[node name="Circle" type="Node2D" unique_id=752416547] +script = ExtResource("1_ortn3") diff --git a/demo/Blackholio/client-godot/Circle2D.cs b/demo/Blackholio/client-godot/Circle2D.cs new file mode 100644 index 00000000000..c5b1e6f60ad --- /dev/null +++ b/demo/Blackholio/client-godot/Circle2D.cs @@ -0,0 +1,41 @@ +using Godot; + +public partial class Circle2D : Node2D +{ + private float _radius = 10.0f; + private Color _color = Colors.Brown; + + [Export] + public float Radius + { + get => _radius; + set + { + if (Mathf.IsEqualApprox(_radius, value)) + { + return; + } + + _radius = value; + QueueRedraw(); + } + } + + [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..05ecbbb2c3a --- /dev/null +++ b/demo/Blackholio/client-godot/Circle2D.cs.uid @@ -0,0 +1 @@ +uid://u1eb430g524x diff --git a/demo/Blackholio/client-godot/CircleController.cs b/demo/Blackholio/client-godot/CircleController.cs new file mode 100644 index 00000000000..0dc75b87908 --- /dev/null +++ b/demo/Blackholio/client-godot/CircleController.cs @@ -0,0 +1,133 @@ +using Godot; +using SpacetimeDB.Types; + +public partial class CircleController : EntityController +{ + private static readonly Color[] ColorPalette = + [ + new(175 / 255.0f, 159 / 255.0f, 49 / 255.0f), + new(175 / 255.0f, 116 / 255.0f, 49 / 255.0f), + new(112 / 255.0f, 47 / 255.0f, 252 / 255.0f), + new(51 / 255.0f, 91 / 255.0f, 252 / 255.0f), + 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), + 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 _owner; + + public void Spawn(Circle circle, PlayerController owner) + { + SpawnEntity(circle.EntityId); + Color = ColorPalette[circle.PlayerId % ColorPalette.Length]; + + _owner = owner; + UpdateLabelText(); + } + + public override void _Ready() + { + base._Ready(); + UpdateLabelText(); + } + + public override void _Process(double delta) + { + base._Process(delta); + UpdateScreenLabelPosition(); + } + + public override void OnDelete(EventContext context) + { + if (IsInstanceValid(Label)) + { + Label.QueueFree(); + } + + base.OnDelete(context); + _owner?.OnCircleDeleted(this); + } + + private void UpdateScreenLabelPosition() + { + if (!IsInstanceValid(Label)) + { + return; + } + + Label.Size = Label.GetCombinedMinimumSize(); + var screenPosition = GetGlobalTransformWithCanvas().Origin; + var offset = new Vector2(0.0f, Radius + 8.0f); + Label.Position = screenPosition + offset - (Label.Size / 2.0f); + } + + private void UpdateLabelText() + { + if (!IsInstanceValid(Label) || _owner == null) + { + return; + } + + Label.Text = _owner.Username; + Label.Size = Label.GetCombinedMinimumSize(); + } +} diff --git a/demo/Blackholio/client-godot/CircleController.cs.uid b/demo/Blackholio/client-godot/CircleController.cs.uid new file mode 100644 index 00000000000..c01c4d7659b --- /dev/null +++ b/demo/Blackholio/client-godot/CircleController.cs.uid @@ -0,0 +1 @@ +uid://cd0xlwbeuylpo diff --git a/demo/Blackholio/client-godot/EntityController.cs b/demo/Blackholio/client-godot/EntityController.cs new file mode 100644 index 00000000000..fd7ca37842b --- /dev/null +++ b/demo/Blackholio/client-godot/EntityController.cs @@ -0,0 +1,50 @@ +using Godot; +using SpacetimeDB.Types; + +public partial class EntityController : Circle2D +{ + private const float LerpDurationSec = 0.1f; + + public int EntityId { get; protected set; } + + protected float LerpTime { get; set; } + protected Vector2 LerpStartPosition { get; set; } + protected Vector2 TargetPosition { get; set; } + protected float TargetRadius { get; set; } = 1; + + protected void SpawnEntity(int entityId) + { + EntityId = entityId; + + 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 newVal) + { + LerpTime = 0.0f; + LerpStartPosition = GlobalPosition; + TargetPosition = (Vector2)newVal.Position; + TargetRadius = MassToRadius(newVal.Mass); + } + + public virtual void OnDelete(EventContext context) + { + 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); + } + + public 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..c7e86c3cf36 --- /dev/null +++ b/demo/Blackholio/client-godot/EntityController.cs.uid @@ -0,0 +1 @@ +uid://cap5fd02qvfxf diff --git a/demo/Blackholio/client-godot/Extensions.cs b/demo/Blackholio/client-godot/Extensions.cs new file mode 100644 index 00000000000..98e43acb468 --- /dev/null +++ b/demo/Blackholio/client-godot/Extensions.cs @@ -0,0 +1,17 @@ +using Godot; + +namespace SpacetimeDB.Types +{ + public partial class DbVector2 + { + public static implicit operator Vector2(DbVector2 vec) + { + return new Vector2(vec.X, vec.Y); + } + + public static implicit operator DbVector2(Vector2 vec) + { + return new DbVector2(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..802f367339b --- /dev/null +++ b/demo/Blackholio/client-godot/Extensions.cs.uid @@ -0,0 +1 @@ +uid://1i71lnxdcjog diff --git a/demo/Blackholio/client-godot/Food.tscn b/demo/Blackholio/client-godot/Food.tscn new file mode 100644 index 00000000000..b9055094665 --- /dev/null +++ b/demo/Blackholio/client-godot/Food.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://3gdkr0fla74a"] + +[ext_resource type="Script" uid="uid://dol6i5dumj0xt" path="res://FoodController.cs" id="1_lm7oo"] + +[node name="Food" type="Node2D" unique_id=2957058] +script = ExtResource("1_lm7oo") diff --git a/demo/Blackholio/client-godot/FoodController.cs b/demo/Blackholio/client-godot/FoodController.cs new file mode 100644 index 00000000000..565eaa53f5c --- /dev/null +++ b/demo/Blackholio/client-godot/FoodController.cs @@ -0,0 +1,21 @@ +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 void Spawn(Food food) + { + SpawnEntity(food.EntityId); + Color = ColorPalette[EntityId % ColorPalette.Length]; + } +} diff --git a/demo/Blackholio/client-godot/FoodController.cs.uid b/demo/Blackholio/client-godot/FoodController.cs.uid new file mode 100644 index 00000000000..ed707332126 --- /dev/null +++ b/demo/Blackholio/client-godot/FoodController.cs.uid @@ -0,0 +1 @@ +uid://dol6i5dumj0xt diff --git a/demo/Blackholio/client-godot/GameManager.cs b/demo/Blackholio/client-godot/GameManager.cs index cadad576d5e..9509806380f 100644 --- a/demo/Blackholio/client-godot/GameManager.cs +++ b/demo/Blackholio/client-godot/GameManager.cs @@ -1,6 +1,219 @@ -using Godot; using System; +using System.Collections.Generic; +using Godot; +using SpacetimeDB; +using SpacetimeDB.Types; -public partial class GameManager : Node +public partial class GameManager : Node2D { + private const string ServerUrl = "http://127.0.0.1:3000"; + private const string ModuleName = "blackholio"; + + public static event Action OnConnected; + public static event Action OnSubscriptionApplied; + + [Export] + private Color BackgroundColor { get; set; } = Colors.MidnightBlue; + + [Export] + private float BorderThickness { get; set; } = 2.0f; + + [Export] + private Color BorderColor { get; set; } = Colors.Goldenrod; + + [Export] + private string DefaultPlayerName { get; set; } = "3Blave"; + + public static GameManager Instance { get; private set; } + public static Identity LocalIdentity { get; private set; } + public static DbConnection Conn { get; private set; } + + public static Dictionary Entities { get; } = new(); + public static Dictionary Players { get; } = new(); + + private Instantiator _instantiator; + public Instantiator Instantiator => _instantiator ??= GetNode("Instantiator") ?? new Instantiator(); + + public GameManager() + { + var builder = DbConnection.Builder() + .OnConnect(HandleConnect) + .OnConnectError(HandleConnectError) + .OnDisconnect(HandleDisconnect) + .WithUri(ServerUrl) + .WithDatabaseName(ModuleName); + + AuthToken.Init(); + if (AuthToken.Token != string.Empty) + { + builder = builder.WithToken(AuthToken.Token); + } + + Conn = builder.Build(); + STDBUpdateManager.Add(Conn); + } + + public override void _EnterTree() + { + Instance = this; + } + + public override void _ExitTree() + { + if (Conn != null) + { + STDBUpdateManager.Remove(Conn, true); + Conn = null; + } + + Entities.Clear(); + Players.Clear(); + + if (Instance == this) + { + Instance = null; + } + } + + public static bool IsConnected() => Conn != null && Conn.IsActive; + + public void Disconnect() + { + if (Conn == null) + { + return; + } + + STDBUpdateManager.Remove(Conn, true); + Conn = null; + } + + private void HandleConnect(DbConnection conn, Identity identity, string token) + { + GD.Print("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(); + + 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(); + + 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); + + CameraController.WorldSize = worldSize; + } + + private void CircleOnInsert(EventContext context, Circle insertedValue) + { + var player = GetOrCreatePlayer(insertedValue.PlayerId); + var entityController = Instantiator.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(context); + } + } + + private void FoodOnInsert(EventContext context, Food insertedValue) + { + var entityController = Instantiator.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 = Instantiator.SpawnPlayer(player); + Players[playerId] = playerController; + } + + return playerController; + } } diff --git a/demo/Blackholio/client-godot/GameManager.cs.uid b/demo/Blackholio/client-godot/GameManager.cs.uid index d47cbf7bacd..a2385ceb495 100644 --- a/demo/Blackholio/client-godot/GameManager.cs.uid +++ b/demo/Blackholio/client-godot/GameManager.cs.uid @@ -1 +1 @@ -uid://c3evgwekyhyuy +uid://p2e72osy6dx5 diff --git a/demo/Blackholio/client-godot/Instantiator.cs b/demo/Blackholio/client-godot/Instantiator.cs new file mode 100644 index 00000000000..0b39102ac68 --- /dev/null +++ b/demo/Blackholio/client-godot/Instantiator.cs @@ -0,0 +1,44 @@ +using Godot; +using SpacetimeDB.Types; + +public partial class Instantiator : Node +{ + [Export] + public PackedScene CircleScene { get; set; } + + [Export] + public PackedScene FoodScene { get; set; } + + [Export] + public PackedScene PlayerScene { get; set; } + + public CircleController SpawnCircle(Circle circle, PlayerController owner) + { + var entityController = InstantiateNode(CircleScene, $"Circle - {circle.EntityId}"); + entityController.Spawn(circle, owner); + owner.OnCircleSpawned(entityController); + return entityController; + } + + public FoodController SpawnFood(Food food) + { + var entityController = InstantiateNode(FoodScene, $"Food - {food.EntityId}"); + entityController.Spawn(food); + return entityController; + } + + public PlayerController SpawnPlayer(Player player) + { + var playerController = InstantiateNode(PlayerScene, $"PlayerController - {player.Name}"); + playerController.Initialize(player); + return playerController; + } + + private T InstantiateNode(PackedScene scene, string nodeName) where T : Node, new() + { + var node = scene?.Instantiate() ?? new T(); + node.Name = nodeName; + AddChild(node); + return node; + } +} diff --git a/demo/Blackholio/client-godot/Instantiator.cs.uid b/demo/Blackholio/client-godot/Instantiator.cs.uid new file mode 100644 index 00000000000..1cd24bc9033 --- /dev/null +++ b/demo/Blackholio/client-godot/Instantiator.cs.uid @@ -0,0 +1 @@ +uid://bm2c8ov8aaoi8 diff --git a/demo/Blackholio/client-godot/Player.tscn b/demo/Blackholio/client-godot/Player.tscn new file mode 100644 index 00000000000..f5f6a37c044 --- /dev/null +++ b/demo/Blackholio/client-godot/Player.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://blicbcj7jt37k"] + +[ext_resource type="Script" uid="uid://d3l75ikpjh23o" path="res://PlayerController.cs" id="1_xhfnw"] + +[node name="Player" type="Node2D" unique_id=1748805711] +script = ExtResource("1_xhfnw") diff --git a/demo/Blackholio/client-godot/PlayerController.cs b/demo/Blackholio/client-godot/PlayerController.cs new file mode 100644 index 00000000000..21a89c8d22d --- /dev/null +++ b/demo/Blackholio/client-godot/PlayerController.cs @@ -0,0 +1,138 @@ +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(); + + // Automated testing members. + private bool _testInputEnabled; + private Vector2 _testInput; + 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 void Initialize(Player player) + { + _playerId = player.PlayerId; + if (player.Identity == GameManager.LocalIdentity) + { + Local = this; + } + } + + public override void _ExitTree() + { + foreach (var circle in _ownedCircles.ToList()) + { + if (GodotObject.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() + { + return (int)_ownedCircles + .Select(circle => GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId)) + .Sum(entity => entity?.Mass ?? 0); + } + + public Vector2? CenterOfMass() + { + if (_ownedCircles.Count == 0) + { + return null; + } + + Vector2 totalPos = Vector2.Zero; + float 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; + } + + return totalMass > 0.0f ? totalPos / totalMass : null; + } + + 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); + + if (_testInputEnabled) + { + direction = _testInput; + } + + GameManager.Conn.Reducers.UpdatePlayerInput(direction); + } + + public void SetTestInput(Vector2 input) => _testInput = input; + public void EnableTestInput() => _testInputEnabled = true; +} diff --git a/demo/Blackholio/client-godot/PlayerController.cs.uid b/demo/Blackholio/client-godot/PlayerController.cs.uid new file mode 100644 index 00000000000..69530043c59 --- /dev/null +++ b/demo/Blackholio/client-godot/PlayerController.cs.uid @@ -0,0 +1 @@ +uid://d3l75ikpjh23o diff --git a/demo/Blackholio/client-godot/STDBUpdateManager.cs b/demo/Blackholio/client-godot/STDBUpdateManager.cs index fde9ee42eb9..c2662b5b310 100644 --- a/demo/Blackholio/client-godot/STDBUpdateManager.cs +++ b/demo/Blackholio/client-godot/STDBUpdateManager.cs @@ -7,6 +7,7 @@ namespace SpacetimeDB public partial class STDBUpdateManager : Node { private const string SingletonNodeName = nameof(STDBUpdateManager); + private static STDBUpdateManager _instance; private static STDBUpdateManager Instance => EnsureInstance(); @@ -43,7 +44,7 @@ private static STDBUpdateManager EnsureInstance() { Name = SingletonNodeName, }; - root.AddChild(_instance); + root.AddChild(_instance, false, InternalMode.Front); return _instance; } diff --git a/demo/Blackholio/client-godot/STDBUpdateManager.cs.uid b/demo/Blackholio/client-godot/STDBUpdateManager.cs.uid index 1f5786b30da..b34e8113fbd 100644 --- a/demo/Blackholio/client-godot/STDBUpdateManager.cs.uid +++ b/demo/Blackholio/client-godot/STDBUpdateManager.cs.uid @@ -1 +1 @@ -uid://wdjx7u1fmhid +uid://dx02o6rbuv8fq diff --git a/demo/Blackholio/client-godot/icon.svg.import b/demo/Blackholio/client-godot/icon.svg.import index 739ceea4a57..d18dbf4ff0c 100644 --- a/demo/Blackholio/client-godot/icon.svg.import +++ b/demo/Blackholio/client-godot/icon.svg.import @@ -2,7 +2,7 @@ importer="texture" type="CompressedTexture2D" -uid="uid://bhy3nrxvs0bg" +uid="uid://ckfrm5vae4u3p" path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" metadata={ "vram_texture": false diff --git a/demo/Blackholio/client-godot/main.tscn b/demo/Blackholio/client-godot/main.tscn index 4dfee01afd9..c9a65f667bd 100644 --- a/demo/Blackholio/client-godot/main.tscn +++ b/demo/Blackholio/client-godot/main.tscn @@ -1,6 +1,20 @@ [gd_scene format=3 uid="uid://cjb7808stemnn"] -[ext_resource type="Script" uid="uid://c3evgwekyhyuy" path="res://GameManager.cs" id="1_ig7tw"] +[ext_resource type="Script" uid="uid://p2e72osy6dx5" path="res://GameManager.cs" id="1_7mycd"] +[ext_resource type="Script" uid="uid://bm2c8ov8aaoi8" path="res://Instantiator.cs" id="2_7mycd"] +[ext_resource type="PackedScene" uid="uid://j74mpcyku2um" path="res://Circle.tscn" id="3_h2yge"] +[ext_resource type="PackedScene" uid="uid://3gdkr0fla74a" path="res://Food.tscn" id="4_1bvp3"] +[ext_resource type="PackedScene" uid="uid://blicbcj7jt37k" path="res://Player.tscn" id="5_lquwl"] +[ext_resource type="Script" uid="uid://b0l7e61svh6b4" path="res://CameraController.cs" id="6_7mycd"] -[node name="Main" type="Node2D" unique_id=126559554] -script = ExtResource("1_ig7tw") +[node name="Main" type="Node2D" unique_id=211175705] +script = ExtResource("1_7mycd") + +[node name="Instantiator" type="Node2D" parent="." unique_id=182682719] +script = ExtResource("2_7mycd") +CircleScene = ExtResource("3_h2yge") +FoodScene = ExtResource("4_1bvp3") +PlayerScene = ExtResource("5_lquwl") + +[node name="Camera2D" type="Camera2D" parent="." unique_id=1030276943] +script = ExtResource("6_7mycd") 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..df2fe0a92ee --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Reducers/EnterGame.g.cs.uid @@ -0,0 +1 @@ +uid://dnktvv6ucqo8o 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..55840e521ea --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Reducers/UpdatePlayerInput.g.cs.uid @@ -0,0 +1 @@ +uid://ba2y10myukhjo 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..d45139d4160 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs.uid @@ -0,0 +1 @@ +uid://ctc160vqwf6yf 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..a857db9fd2b --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Circle.g.cs.uid @@ -0,0 +1 @@ +uid://bcddkkce7vglq 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..161eeb30806 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Config.g.cs.uid @@ -0,0 +1 @@ +uid://bwsauj58rorqh 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..b168ce4928f --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Entity.g.cs.uid @@ -0,0 +1 @@ +uid://dkcmtlay2kya1 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..c168e6d4866 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Food.g.cs.uid @@ -0,0 +1 @@ +uid://dbh8bqbmcfmwx 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..e12e4c0a99a --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Player.g.cs.uid @@ -0,0 +1 @@ +uid://cuwt8n7drvq6y 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..4fd4f147dc7 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/Circle.g.cs.uid @@ -0,0 +1 @@ +uid://5g25xoh6rbcd 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..e8b6b2d279f --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/Config.g.cs.uid @@ -0,0 +1 @@ +uid://b4f33auffxcff 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..7be1e55b405 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/DbVector2.g.cs.uid @@ -0,0 +1 @@ +uid://b0d2uwpmc35ve 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..b66f30bf44f --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/Entity.g.cs.uid @@ -0,0 +1 @@ +uid://df5fpdyx2loie 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..0af6321c5fd --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/Food.g.cs.uid @@ -0,0 +1 @@ +uid://bc26loskn8hm6 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..5f4ac353f23 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/MoveAllPlayersTimer.g.cs.uid @@ -0,0 +1 @@ +uid://wemroaeunvsa 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..745db96f2a6 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/Player.g.cs.uid @@ -0,0 +1 @@ +uid://bqvnk2o68p3ve 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..257bae214d2 --- /dev/null +++ b/demo/Blackholio/client-godot/module_bindings/Types/SpawnFoodTimer.g.cs.uid @@ -0,0 +1 @@ +uid://bub0q45hec0b8 From 335959bbb68d64c320ff667e9d8af3fea1577fc4 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 24 Apr 2026 14:48:12 -0500 Subject: [PATCH 04/12] Update tutorial up to part 3 --- .../00500-godot-tutorial/00200-part-1.md | 23 +- .../00500-godot-tutorial/00300-part-2.md | 166 ++-- .../00500-godot-tutorial/00400-part-3.md | 767 ++++++++++-------- 3 files changed, 516 insertions(+), 440 deletions(-) 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 index 9dc516c0ab6..2a44911a003 100644 --- 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 @@ -50,7 +50,7 @@ We will add the SpacetimeDB SDK using NuGet. Godot does not initialize a C# Proj dotnet add package SpacetimeDB.ClientSDK ``` -The SpacetimeDB Godot SDK provides helpful tools for integrating SpacetimeDB into Godot, including a connection manager which will synchronize your Godot client's state with your SpacetimeDB database in accordance with your subscription queries. +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. ### Add the GameManager to the Scene @@ -80,23 +80,4 @@ Our Godot project is all set up! If you press play, it will show a blank screen, ### 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. - - - - - - - - - - - - -### Add the SpacetimeDB Connection Manager - -You can now use the C# SDK, create new connections and manage them the best way that suits your needs. - -We provide `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. - -You can add a Global `STDBUpdateManager` Autoload to your project but it's not necessary since it would be lazy loaded on demand when needed. \ No newline at end of file +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 index 3df6b0b735a..54aaa226f62 100644 --- 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 @@ -1,6 +1,6 @@ --- title: 2 - Connecting to SpacetimeDB -slug: /tutorials/unity/part-2 +slug: /tutorials/godot/part-2 --- import Tabs from '@theme/Tabs'; @@ -17,16 +17,14 @@ This progressive tutorial is continued from [part 1](./00200-part-1.md). 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 for your Unity project lives -├── Assembly-CSharp.csproj -├── Assets/ -│ └── module_bindings/ # This directory contains the client logic to communicate with the module -├── Library/ -├── ... # rest of the Unity files +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 └── 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. +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 @@ -47,7 +45,7 @@ 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. +This command creates a new folder named `spacetimedb` inside of your Godot project `blackholio` directory and sets up the SpacetimeDB server project with C# as the programming language. @@ -57,7 +55,7 @@ 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. +This command creates a new folder named `spacetimedb` inside of your Godot project `blackholio` directory and sets up the SpacetimeDB server project with Rust as the programming language. @@ -69,7 +67,7 @@ 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. +This command creates a new folder named `spacetimedb` inside of your Godot project `blackholio` directory and sets up the SpacetimeDB server project with C++ as the programming language. @@ -638,27 +636,27 @@ 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 Unity 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/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/spacetimedb` directory run the following command: + ```sh -spacetime generate --lang csharp --out-dir ../Assets/module_bindings +spacetime generate --lang csharp --out-dir ../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 which contain the code generated types and reducer functions that are defined in your module, but usable on the client. ``` ├── Reducers @@ -678,30 +676,18 @@ 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. - -> 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. -> -> ```csharp -> namespace System.Runtime.CompilerServices -> { -> internal static class IsExternalInit { } -> } -> ``` -> -> Add this snippet to the bottom of your `GameManager.cs` file in your Unity project. This will hopefully be resolved in Unity soon. +This will also generate a file in the `module_bindings/SpacetimeDBClient.g.cs` directory 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 set up Unity to connect your Unity client to the server. Replace your imports at the top of the `GameManager.cs` file with: +At this point we can connect your Godot client to the server. Replace your imports at the top of the `GameManager.cs` file with: ```cs using System; -using System.Collections; using System.Collections.Generic; using SpacetimeDB; using SpacetimeDB.Types; -using UnityEngine; +using Godot; ``` Replace the implementation of the `GameManager` class with the following. @@ -709,49 +695,69 @@ Replace the implementation of the `GameManager` class with the following. ```cs public class GameManager : MonoBehaviour { - const string SERVER_URL = "http://127.0.0.1:3000"; - const string MODULE_NAME = "blackholio"; + 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; - public static event Action OnConnected; - public static event Action OnSubscriptionApplied; + [Export] + private float BorderThickness { get; set; } = 5.0f; - public float borderThickness = 2; - public Material borderMaterial; + [Export] + private Color BorderColor { get; set; } = Colors.Goldenrod; - public static GameManager Instance { get; private set; } + [Export] + private string DefaultPlayerName { get; set; } = "3Blave"; + + private static GameManager Instance { get; private set; } public static Identity LocalIdentity { get; private set; } public static DbConnection Conn { get; private set; } - private void Start() + public GameManager() { - Instance = this; - Application.targetFrameRate = 60; - - // In order to build a connection to SpacetimeDB we need to register - // our callbacks and specify a SpacetimeDB server URI and module name. var builder = DbConnection.Builder() - .OnConnect(HandleConnect) - .OnConnectError(HandleConnectError) - .OnDisconnect(HandleDisconnect) - .WithUri(SERVER_URL) - .WithDatabaseName(MODULE_NAME); - - // If the user has a SpacetimeDB auth token stored in the Unity PlayerPrefs, - // we can use it to authenticate the connection. - if (AuthToken.Token != "") - { - builder = builder.WithToken(AuthToken.Token); - } - - // Building the connection will establish a connection to the SpacetimeDB - // server. - Conn = builder.Build(); + .OnConnect(HandleConnect) + .OnConnectError(HandleConnectError) + .OnDisconnect(HandleDisconnect) + .WithUri(ServerUrl) + .WithDatabaseName(DatabaseName); + + AuthToken.Init(); + if (AuthToken.Token != string.Empty) + { + builder = builder.WithToken(AuthToken.Token); + } + + Conn = builder.Build(); + STDBUpdateManager.Add(Conn); } + + public override void _EnterTree() + { + Instance = this; + } + + public override void _ExitTree() + { + Disconnect(); + + if (Instance == this) + { + Instance = null; + } + } // Called when we connect to SpacetimeDB and receive our client identity - void HandleConnect(DbConnection _conn, Identity identity, string token) + private void HandleConnect(DbConnection _conn, Identity identity, string token) { - Debug.Log("Connected."); + GD.Print("Connected."); AuthToken.SaveToken(token); LocalIdentity = identity; @@ -763,42 +769,40 @@ public class GameManager : MonoBehaviour .SubscribeToAllTables(); } - void HandleConnectError(Exception ex) + private void HandleConnectError(Exception ex) { - Debug.LogError($"Connection error: {ex}"); + GD.PrintErr($"Connection error: {ex}"); } - void HandleDisconnect(DbConnection _conn, Exception ex) + private void HandleDisconnect(DbConnection _conn, Exception ex) { - Debug.Log("Disconnected."); + GD.Print("Disconnected."); if (ex != null) { - Debug.LogException(ex); + GD.PrintException(ex); } } private void HandleSubscriptionApplied(SubscriptionEventContext ctx) { - Debug.Log("Subscription applied!"); + GD.Print("Subscription applied!"); OnSubscriptionApplied?.Invoke(); } - public static bool IsConnected() - { - return Conn != null && Conn.IsActive; - } + public static bool IsConnected() => Conn != null && Conn.IsActive; + public void Disconnect() { - Conn.Disconnect(); + STDBUpdateManager.Remove(Conn, true); Conn = null; } } ``` -Here we configure the connection to the database, by passing it some callbacks in addition to providing the `SERVER_URI` and `MODULE_NAME` to the connection. When the client connects, the SpacetimeDB SDK will call the `HandleConnect` method, allowing us to start up the game. +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 pass 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 Unity client's SDK client cache. +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. --- @@ -808,9 +812,9 @@ The "SDK client cache" is a client-side view of the database defined by the supp --- -Now we're ready to connect the client and server. Press the play button in Unity. +Now we're ready to connect the client and server. Press the play button in Godot. -If all went well you should see the below output in your Unity logs. +If all went well you should see the below output in your Godot logs. ``` SpacetimeDBClient: Connecting to ws://127.0.0.1:3000 blackholio @@ -830,6 +834,6 @@ spacetime logs --server local blackholio ### Next Steps -You've learned how to setup a Unity project with the SpacetimeDB SDK, write a basic SpacetimeDB server module, and how to connect your Unity client to SpacetimeDB. That's pretty much all there is to the setup. You're now ready to start building the game. +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 Unity. +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 index 8de2e8d1610..4c04d9f9c4d 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 @@ -1,6 +1,6 @@ --- title: 3 - Gameplay -slug: /tutorials/unity/part-3 +slug: /tutorials/Godot/part-3 --- import Tabs from '@theme/Tabs'; @@ -16,7 +16,7 @@ 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 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 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. @@ -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))] @@ -623,7 +623,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 @@ -914,31 +914,38 @@ Deleting the data is optional in this case, but in case you've been messing arou ### Creating the Arena -Now that we've set up our server logic to spawn food and players, let's continue developing our Unity client to display what we have so far. +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 `SetupArena` and `CreateBorderCube` methods to your `GameManager` class: +Start by adding the `SetupArena` method 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 - } - - 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 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); + } ``` In your `HandleSubscriptionApplied` let's now call `SetupArena` method. Modify your `HandleSubscriptionApplied` method as in the below. @@ -946,7 +953,7 @@ In your `HandleSubscriptionApplied` let's now call `SetupArena` method. Modify y ```cs private void HandleSubscriptionApplied(SubscriptionEventContext ctx) { - Debug.Log("Subscription applied!"); + GD.Print("Subscription applied!"); OnSubscriptionApplied?.Invoke(); // Once we have the initial subscription sync'd to the client cache @@ -958,230 +965,290 @@ In your `HandleSubscriptionApplied` let's now call `SetupArena` method. Modify y 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 view, select the `GameManager` object. Click on the `Border Material` property and choose `Sprites-Default`. - -### Creating GameObjects +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. -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 `GameObject`s on the screen. +### Instantiating Nodes -Let's start by making some controller scripts for each of the game objects we'd like to have in our scene. In the project window, right-click and select `Create > C# Script`. Name the new script `PlayerController.cs`. Repeat that process for `CircleController.cs` and `FoodController.cs`. We'll modify the contents of these files later. +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. -Now let's make some prefabs for our game objects. In the scene hierarchy window, create a new `GameObject` by right-clicking and selecting: +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. -``` -2D Object > Sprites > Circle -``` +#### Circle2D -Rename the new game object in the scene to `CirclePrefab`. Next in the `Inspector` window click the `Add Component` button and add the `Circle Controller` script component that we just created. Finally drag the object into the `Project` folder. Once the prefab file is created, delete the `CirclePrefab` object from the scene. We'll use this prefab to draw the circles that a player controls. +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: -Next repeat that same process for the `FoodPrefab` and `Food Controller` component. - -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: +```cs +using Godot; -``` -UI > Text - Text Mesh Pro -``` +public partial class Circle2D : Node2D +{ + private float _radius = 10.0f; + [Export] + public float Radius + { + get => _radius; + set + { + if (Mathf.IsEqualApprox(_radius, value)) return; -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`. + _radius = value; + QueueRedraw(); + } + } -Finally we need to make the `PlayerPrefab`. In the hierarchy window, create a new `GameObject` by right-clicking and selecting: + private Color _color = Colors.Brown; + [Export] + public Color Color + { + get => _color; + set + { + if (_color == value) return; -``` -Create Empty + _color = value; + QueueRedraw(); + } + } + + public override void _Draw() => DrawCircle(Vector2.Zero, Radius, Color); +} ``` -Rename the game object to `PlayerPrefab`. Next in the `Inspector` window click the `Add Component` button and add the `Player Controller` script component that we just created. Next drag the object into the `Project` folder. Once the prefab file is created, delete the `PlayerPrefab` object from the scene. - #### 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 file called `EntityController.cs` and replace its contents with: +Create a new C# script called `EntityController.cs` and replace its contents with: ```cs +using Godot; using SpacetimeDB.Types; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using Unity.VisualScripting; -using UnityEngine; -public abstract class EntityController : MonoBehaviour +public abstract partial class EntityController : Circle2D { - const float LERP_DURATION_SEC = 0.1f; - - private static readonly int ShaderColorProperty = Shader.PropertyToID("_Color"); - - [DoNotSerialize] public int EntityId; - - protected float LerpTime; - protected Vector3 LerpStartPosition; - protected Vector3 LerpTargetPosition; - protected Vector3 TargetScale; - - protected virtual void Spawn(int entityId) - { - EntityId = entityId; - - var entity = GameManager.Conn.Db.Entity.EntityId.Find(entityId); - LerpStartPosition = LerpTargetPosition = transform.position = (Vector2)entity.Position; - transform.localScale = Vector3.one; - TargetScale = MassToScale(entity.Mass); - } - - public void SetColor(Color color) - { - GetComponent().material.SetColor(ShaderColorProperty, color); - } - - public virtual void OnEntityUpdated(Entity newVal) - { - LerpTime = 0.0f; - LerpStartPosition = transform.position; - LerpTargetPosition = (Vector2)newVal.Position; - TargetScale = MassToScale(newVal.Mass); - } - - public virtual void OnDelete(EventContext context) - { - Destroy(gameObject); - } - - public virtual void Update() - { - // Interpolate position and scale - LerpTime = Mathf.Min(LerpTime + Time.deltaTime, LERP_DURATION_SEC); - transform.position = Vector3.Lerp(LerpStartPosition, LerpTargetPosition, LerpTime / LERP_DURATION_SEC); - transform.localScale = Vector3.Lerp(transform.localScale, TargetScale, Time.deltaTime * 8); - } - - public static Vector3 MassToScale(int mass) - { - var diameter = MassToDiameter(mass); - return new Vector3(diameter, diameter, 1); - } - - public static float MassToRadius(int mass) => Mathf.Sqrt(mass); - public static float MassToDiameter(int mass) => MassToRadius(mass) * 2; + 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; } = 1; + + 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 newVal) + { + LerpTime = 0.0f; + LerpStartPosition = GlobalPosition; + TargetPosition = (Vector2)newVal.Position; + TargetRadius = MassToRadius(newVal.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 just provides some helper functions and basic functionality to manage our game objects based on entity updates. +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: -------------------------- > 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 `UnityEngine.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 `GodotEngine.Vector2`. To fix this, let's also create a new `Extensions.cs` script and replace the contents with: ```cs -using SpacetimeDB.Types; -using UnityEngine; +using Godot; namespace SpacetimeDB.Types { public partial class DbVector2 { - public static implicit operator Vector2(DbVector2 vec) - { - return new Vector2(vec.X, vec.Y); - } - - public static implicit operator DbVector2(Vector2 vec) - { - return new DbVector2(vec.x, vec.y); - } + 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 Unity `Vector2` type. +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: ```cs -using System; -using System.Collections.Generic; -using SpacetimeDB; +using Godot; using SpacetimeDB.Types; -using UnityEngine; -public class CircleController : EntityController +public partial class CircleController : EntityController { - public static Color[] ColorPalette = new[] - { - //Yellow - (Color)new Color32(175, 159, 49, 255), - (Color)new Color32(175, 116, 49, 255), - - //Purple - (Color)new Color32(112, 47, 252, 255), - (Color)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), - - //Blue - (Color)new Color32(2, 188, 250, 255), - (Color)new Color32(7, 50, 251, 255), - (Color)new Color32(2, 28, 146, 255), - }; - - private PlayerController Owner; - - public void Spawn(Circle circle, PlayerController owner) - { - base.Spawn(circle.EntityId); - SetColor(ColorPalette[circle.PlayerId % ColorPalette.Length]); - - this.Owner = owner; - GetComponentInChildren().text = owner.Username; - } - - public override void OnDelete(EventContext context) - { - base.OnDelete(context); - Owner.OnCircleDeleted(this); - } + 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 spawn function which takes a `Circle` (same type that's in our `circle` table) and a `PlayerController` which sets the color based on the circle's player ID, as well as setting the text of the Cricle 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 the text of the Circle to be the player's username. -Note that the `CircleController` inherits from the `EntityController`, not `MonoBehavior`. +// TODO: ------------------------------ +Explain Label stuff. +// TODO: ------------------------------ + +Note that the `CircleController` inherits from the `EntityController`. #### FoodController Next open the `FoodController.cs` file and replace the contents with: ```cs +using Godot; using SpacetimeDB.Types; -using Unity.VisualScripting; -using UnityEngine; -public class FoodController : EntityController +public partial class FoodController : EntityController { - public static Color[] ColorPalette = new[] - { - (Color)new Color32(119, 252, 173, 255), - (Color)new Color32(76, 250, 146, 255), - (Color)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), - }; - - public void Spawn(Food food) - { - base.Spawn(food.EntityId); - SetColor(ColorPalette[EntityId % ColorPalette.Length]); - } + 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]) { } } ``` @@ -1192,161 +1259,169 @@ Open the `PlayerController` script and modify the contents of the `PlayerControl ```cs using System.Collections.Generic; using System.Linq; -using SpacetimeDB; +using Godot; using SpacetimeDB.Types; -using UnityEngine; -public class PlayerController : MonoBehaviour +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 List OwnedCircles = new List(); + private int _playerId; + private float _lastMovementSendTimestamp; + private Vector2? _lockInputPosition; + private readonly List _ownedCircles = new(); - public string Username => GameManager.Conn.Db.Player.PlayerId.Find(PlayerId).Name; - public int NumberOfOwnedCircles => OwnedCircles.Count; + 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 void Initialize(Player player) + public PlayerController(Player player) { - PlayerId = player.PlayerId; + _playerId = player.PlayerId; if (player.Identity == GameManager.LocalIdentity) { Local = this; } } - private void OnDestroy() + public override void _ExitTree() { - // If we have any circles, destroy them - foreach (var circle in OwnedCircles) + foreach (var circle in _ownedCircles.ToList()) { - if (circle != null) + if (IsInstanceValid(circle)) { - Destroy(circle.gameObject); + circle.QueueFree(); } } - OwnedCircles.Clear(); + + _ownedCircles.Clear(); + if (Local == this) + { + Local = null; + } } public void OnCircleSpawned(CircleController circle) { - OwnedCircles.Add(circle); + _ownedCircles.Add(circle); } public void OnCircleDeleted(CircleController deletedCircle) { - // This means we got eaten - if (OwnedCircles.Remove(deletedCircle) && IsLocalPlayer && OwnedCircles.Count == 0) - { - // DeathScreen.Instance.SetVisible(true); - } + _ownedCircles.Remove(deletedCircle); } - public int TotalMass() - { - return (int)OwnedCircles + public int TotalMass() => _ownedCircles .Select(circle => GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId)) - .Sum(e => e?.Mass ?? 0); //If this entity is being deleted on the same frame that we're moving, we can have a null entity here. - } + .Sum(entity => entity?.Mass ?? 0); public Vector2? CenterOfMass() { - if (OwnedCircles.Count == 0) - { - return null; - } + if (_ownedCircles.Count == 0) return null; - Vector2 totalPos = Vector2.zero; - float totalMass = 0; - foreach (var circle in OwnedCircles) + var totalPos = Vector2.Zero; + var totalMass = 0.0f; + foreach (var circle in _ownedCircles) { var entity = GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId); - var position = circle.transform.position; - totalPos += (Vector2)position * entity.Mass; + if (entity == null) continue; + + totalPos += circle.GlobalPosition * entity.Mass; totalMass += entity.Mass; } - return totalPos / totalMass; + return totalMass > 0.0f ? totalPos / totalMass : null; } - private void OnGUI() + public override void _Process(double delta) { - if (!IsLocalPlayer || !GameManager.IsConnected()) + if (!IsLocalPlayer || NumberOfOwnedCircles == 0 || !GameManager.IsConnected()) return; + + var lockTogglePressed = Input.IsPhysicalKeyPressed(Key.Q); + if (lockTogglePressed && !_lockInputTogglePressed) { - return; + if (_lockInputPosition.HasValue) + { + _lockInputPosition = null; + } + else + { + _lockInputPosition = GetViewport().GetMousePosition(); + } } + _lockInputTogglePressed = lockTogglePressed; - GUI.Label(new Rect(0, 0, 100, 50), $"Total Mass: {TotalMass()}"); - } + var nowSeconds = Time.GetTicksMsec() / 1000.0f; + if (nowSeconds - _lastMovementSendTimestamp < SEND_UPDATES_FREQUENCY) return; - //Automated testing members - private bool testInputEnabled; - private Vector2 testInput; + _lastMovementSendTimestamp = nowSeconds; - public void SetTestInput(Vector2 input) => testInput = input; - public void EnableTestInput() => testInputEnabled = true; + 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 `PrefabManager.cs` script which we can use as a factory for creating prefabs. Replace the contents of the file with: +Let's also add a new `Instantiator.cs` script which we can use as a factory for instancing nodes. Replace the contents of the file with: ```cs +using Godot; using SpacetimeDB.Types; -using System.Collections; -using System.Collections.Generic; -using UnityEngine; -public class PrefabManager : MonoBehaviour +public partial class Instantiator : Node { - private static PrefabManager Instance; - - public CircleController CirclePrefab; - public FoodController FoodPrefab; - public PlayerController PlayerPrefab; - - private void Awake() + public CircleController SpawnCircle(Circle circle, PlayerController owner) { - Instance = this; - } - - public static CircleController SpawnCircle(Circle circle, PlayerController owner) - { - var entityController = Instantiate(Instance.CirclePrefab); - entityController.name = $"Circle - {circle.EntityId}"; - entityController.Spawn(circle, owner); - owner.OnCircleSpawned(entityController); + var entityController = new CircleController(circle, owner) + { + Name = $"Circle - {circle.EntityId}", + }; + + AddChild(entityController); + return entityController; } - public static FoodController SpawnFood(Food food) + public FoodController SpawnFood(Food food) { - var entityController = Instantiate(Instance.FoodPrefab); - entityController.name = $"Food - {food.EntityId}"; - entityController.Spawn(food); + var entityController = new FoodController(food) + { + Name = $"Food - {food.EntityId}", + }; + + AddChild(entityController); + return entityController; } - public static PlayerController SpawnPlayer(Player player) + public PlayerController SpawnPlayer(Player player) { - var playerController = Instantiate(Instance.PlayerPrefab); - playerController.name = $"PlayerController - {player.Name}"; - playerController.Initialize(player); + var playerController = new PlayerController(player) + { + Name = $"Player - {player.Name}" + }; + + AddChild(playerController); + return playerController; } } ``` -In the scene hierarchy, select the `GameManager` object and add the `Prefab Manager` script as a component to the `GameManager` object. Drag the corresponding `CirclePrefab`, `FoodPrefab`, and `PlayerPrefab` prefabs we created earlier from the project view into their respective slots in the `Prefab Manager`. Save the scene. +In the Scene dock, add a child `Node2D` to `Main`, name it "Instantiator" and add the `Instantiator` script to it. Save the Main scene. ### Hooking up the Data -We've now prepared our Unity project so that we can hook up the data from our tables to the Unity game objects and have them drawn on the screen. +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. 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: @@ -1363,7 +1438,7 @@ Next lets add some callbacks when rows change in the database. Modify the `Handl // Called when we connect to SpacetimeDB and receive our client identity void HandleConnect(DbConnection conn, Identity identity, string token) { - Debug.Log("Connected."); + GD.Print("Connected."); AuthToken.SaveToken(token); LocalIdentity = identity; @@ -1376,7 +1451,6 @@ Next lets add some callbacks when rows change in the database. Modify the `Handl OnConnected?.Invoke(); - // Request all tables Conn.SubscriptionBuilder() .OnApplied(HandleSubscriptionApplied) .SubscribeToAllTables(); @@ -1386,56 +1460,55 @@ Next lets add some callbacks when rows change in the database. Modify the `Handl Next add the following implementations for those callbacks to the `GameManager` class. ```cs - private static void CircleOnInsert(EventContext context, Circle insertedValue) + private void CircleOnInsert(EventContext context, Circle insertedValue) { var player = GetOrCreatePlayer(insertedValue.PlayerId); - var entityController = PrefabManager.SpawnCircle(insertedValue, player); - Entities.Add(insertedValue.EntityId, entityController); + var entityController = Instantiator.SpawnCircle(insertedValue, player); + Entities[insertedValue.EntityId] = entityController; } - private static void EntityOnUpdate(EventContext context, Entity oldEntity, Entity newEntity) + private 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); } - entityController.OnEntityUpdated(newEntity); } - private static void EntityOnDelete(EventContext context, Entity oldEntity) + private void EntityOnDelete(EventContext context, Entity oldEntity) { if (Entities.Remove(oldEntity.EntityId, out var entityController)) { - entityController.OnDelete(context); + entityController.OnDelete(); } } - private static void FoodOnInsert(EventContext context, Food insertedValue) + private void FoodOnInsert(EventContext context, Food insertedValue) { - var entityController = PrefabManager.SpawnFood(insertedValue); - Entities.Add(insertedValue.EntityId, entityController); + var entityController = Instantiator.SpawnFood(insertedValue); + Entities[insertedValue.EntityId] = entityController; } - private static void PlayerOnInsert(EventContext context, Player insertedPlayer) + private void PlayerOnInsert(EventContext context, Player insertedPlayer) { GetOrCreatePlayer(insertedPlayer.PlayerId); } - private static void PlayerOnDelete(EventContext context, Player deletedvalue) + private 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); + playerController.QueueFree(); } } - private static PlayerController GetOrCreatePlayer(int playerId) + private PlayerController GetOrCreatePlayer(int playerId) { if (!Players.TryGetValue(playerId, out var playerController)) { var player = Conn.Db.Player.PlayerId.Find(playerId); - playerController = PrefabManager.SpawnPlayer(player); - Players.Add(playerId, playerController); + playerController = Instantiator.SpawnPlayer(player); + Players[playerId] = playerController; } return playerController; @@ -1447,70 +1520,89 @@ Next add the following implementations for those callbacks to the `GameManager` 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 -using System.Collections; -using System.Collections.Generic; -using UnityEngine; +using Godot; -public class CameraController : MonoBehaviour +public partial class CameraController : Camera2D { - public static float WorldSize = 0.0f; + public static float WorldSize { get; set; } + + [Export] + public float BaseVisibleRadius { get; set; } = 50.0f; + + [Export] + public float FollowLerpSpeed { get; set; } = 8.0f; - private void LateUpdate() + [Export] + public float ZoomLerpSpeed { get; set; } = 2.0f; + + public override void _Process(double delta) { - var arenaCenterTransform = new Vector3(WorldSize / 2, WorldSize / 2, -10.0f); - if (PlayerController.Local == null || !GameManager.IsConnected()) + var arenaCenter = new Vector2(WorldSize / 2.0f, WorldSize / 2.0f); + var targetPosition = arenaCenter; + + if (PlayerController.Local != null && GameManager.IsConnected()) { - // Set the camera to be in middle of the arena if we are not connected or - // there is no local player - transform.position = arenaCenterTransform; - return; + var centerOfMass = PlayerController.Local.CenterOfMass(); + if (centerOfMass.HasValue) + { + targetPosition = centerOfMass.Value; + } } - var centerOfMass = PlayerController.Local.CenterOfMass(); - if (centerOfMass.HasValue) + GlobalPosition = GlobalPosition.Lerp(targetPosition, (float)delta * FollowLerpSpeed); + + if (PlayerController.Local == null) { - // Set the camera to be the center of mass of the local player - // if the local player has one - transform.position = new Vector3 - { - x = centerOfMass.Value.x, - y = centerOfMass.Value.y, - z = transform.position.z - }; - } else { - transform.position = arenaCenterTransform; + return; } - float targetCameraSize = CalculateCameraSize(PlayerController.Local); - Camera.main.orthographicSize = Mathf.Lerp(Camera.main.orthographicSize, targetCameraSize, Time.deltaTime * 2); + var targetCameraSize = CalculateCameraSize(PlayerController.Local); + var desiredZoom = Vector2.One * (BaseVisibleRadius / Mathf.Max(targetCameraSize, 1.0f)); + Zoom = Zoom.Lerp(desiredZoom, (float)delta * ZoomLerpSpeed); } private float CalculateCameraSize(PlayerController player) { - return 50f + //Base size - Mathf.Min(50, player.TotalMass() / 5) + //Increase camera size with mass - Mathf.Min(player.NumberOfOwnedCircles - 1, 1) * 30; //Zoom out when player splits + return 10.0f + + Mathf.Min(10.0f, player.TotalMass() / 5.0f) + + Mathf.Min(player.NumberOfOwnedCircles - 1, 1) * 30.0f; } } + ``` -Add the `CameraController` as a component to the `Main Camera` object in the scene. +Add a `Camera2D` child node to the Main scene and add the `CameraController` script to it. 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 + 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); + CameraController.WorldSize = worldSize; } ``` @@ -1528,16 +1620,15 @@ The last step is to call the `enter_game` reducer on the server, passing in a us ```cs private void HandleSubscriptionApplied(SubscriptionEventContext ctx) { - Debug.Log("Subscription applied!"); + 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); - - // Call enter game with the player name 3Blave - ctx.Reducers.EnterGame("3Blave"); + + ctx.Reducers.EnterGame(DefaultPlayerName); } ``` @@ -1545,7 +1636,7 @@ The last step is to call the `enter_game` reducer on the server, passing in a us 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. -![Player on screen](/images/unity/part-3-player-on-screen.png) +![Player on screen](/images/Godot/part-3-player-on-screen.png) :::note @@ -1555,9 +1646,9 @@ The label won't be centered at this point. Feel free to adjust it if you like. W ### 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` +- 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 Unity console when starting the game, double check that you have published your module and you have the correct module name specified in your `GameManager`. +- 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 From e6f0ccc40366826643766341874daf7bb8924a36 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 27 Apr 2026 14:13:47 -0500 Subject: [PATCH 05/12] Update Godot docs --- demo/Blackholio/client-godot/AuthToken.cs | 41 +++ .../client-godot/CameraController.cs | 87 ++--- demo/Blackholio/client-godot/Circle.tscn | 6 - demo/Blackholio/client-godot/Circle2D.cs | 66 ++-- .../client-godot/CircleController.cs | 236 ++++++------ .../client-godot/EntityController.cs | 88 +++-- demo/Blackholio/client-godot/Extensions.cs | 13 +- demo/Blackholio/client-godot/Food.tscn | 6 - .../Blackholio/client-godot/FoodController.cs | 6 +- demo/Blackholio/client-godot/GameManager.cs | 348 +++++++----------- demo/Blackholio/client-godot/Instantiator.cs | 143 +++++-- demo/Blackholio/client-godot/Player.tscn | 6 - .../client-godot/PlayerController.cs | 246 ++++++------- demo/Blackholio/client-godot/main.tscn | 14 - demo/Blackholio/client-godot/project.godot | 8 +- .../00500-godot-tutorial/00400-part-3.md | 306 ++++++++------- .../00500-godot-tutorial/00500-part-4.md | 116 +++--- 17 files changed, 867 insertions(+), 869 deletions(-) create mode 100644 demo/Blackholio/client-godot/AuthToken.cs delete mode 100644 demo/Blackholio/client-godot/Circle.tscn delete mode 100644 demo/Blackholio/client-godot/Food.tscn delete mode 100644 demo/Blackholio/client-godot/Player.tscn diff --git a/demo/Blackholio/client-godot/AuthToken.cs b/demo/Blackholio/client-godot/AuthToken.cs new file mode 100644 index 00000000000..b351a005ffb --- /dev/null +++ b/demo/Blackholio/client-godot/AuthToken.cs @@ -0,0 +1,41 @@ +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 token) => TryGetToken(null, out token); + public static bool TryGetToken(string keySuffix, out string token) + { + token = GetToken(keySuffix); + return !string.IsNullOrWhiteSpace(token); + } + + 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}"; + } +} \ No newline at end of file diff --git a/demo/Blackholio/client-godot/CameraController.cs b/demo/Blackholio/client-godot/CameraController.cs index 74e2204aafa..62657d34198 100644 --- a/demo/Blackholio/client-godot/CameraController.cs +++ b/demo/Blackholio/client-godot/CameraController.cs @@ -2,47 +2,48 @@ public partial class CameraController : Camera2D { - public static float WorldSize { get; set; } - - [Export] - public float BaseVisibleRadius { get; set; } = 50.0f; - - [Export] - public float FollowLerpSpeed { get; set; } = 8.0f; - - [Export] - public float ZoomLerpSpeed { get; set; } = 2.0f; - - public override void _Process(double delta) - { - var arenaCenter = new Vector2(WorldSize / 2.0f, WorldSize / 2.0f); - var targetPosition = arenaCenter; - - if (PlayerController.Local != null && GameManager.IsConnected()) - { - var centerOfMass = PlayerController.Local.CenterOfMass(); - if (centerOfMass.HasValue) - { - targetPosition = centerOfMass.Value; - } - } - - 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 float CalculateCameraSize(PlayerController player) - { - return 10.0f - + Mathf.Min(10.0f, player.TotalMass() / 5.0f) - + Mathf.Min(player.NumberOfOwnedCircles - 1, 1) * 30.0f; - } + [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; } diff --git a/demo/Blackholio/client-godot/Circle.tscn b/demo/Blackholio/client-godot/Circle.tscn deleted file mode 100644 index 383433e0e48..00000000000 --- a/demo/Blackholio/client-godot/Circle.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[gd_scene format=3 uid="uid://j74mpcyku2um"] - -[ext_resource type="Script" uid="uid://cd0xlwbeuylpo" path="res://CircleController.cs" id="1_ortn3"] - -[node name="Circle" type="Node2D" unique_id=752416547] -script = ExtResource("1_ortn3") diff --git a/demo/Blackholio/client-godot/Circle2D.cs b/demo/Blackholio/client-godot/Circle2D.cs index c5b1e6f60ad..0c568bc0ae1 100644 --- a/demo/Blackholio/client-godot/Circle2D.cs +++ b/demo/Blackholio/client-godot/Circle2D.cs @@ -1,41 +1,41 @@ using Godot; +[Tool] public partial class Circle2D : Node2D { - private float _radius = 10.0f; - private Color _color = Colors.Brown; + private float _radius = 10.0f; + [Export] + public float Radius + { + get => _radius; + set + { + if (Mathf.IsEqualApprox(_radius, value)) + { + return; + } - [Export] - public float Radius - { - get => _radius; - set - { - if (Mathf.IsEqualApprox(_radius, value)) - { - return; - } + _radius = value; + QueueRedraw(); + } + } - _radius = value; - QueueRedraw(); - } - } + private Color _color = Colors.Brown; + [Export] + public Color Color + { + get => _color; + set + { + if (_color == value) + { + return; + } - [Export] - public Color Color - { - get => _color; - set - { - if (_color == value) - { - return; - } - - _color = value; - QueueRedraw(); - } - } - - public override void _Draw() => DrawCircle(Vector2.Zero, Radius, Color); + _color = value; + QueueRedraw(); + } + } + + public override void _Draw() => DrawCircle(Vector2.Zero, Radius, Color); } diff --git a/demo/Blackholio/client-godot/CircleController.cs b/demo/Blackholio/client-godot/CircleController.cs index 0dc75b87908..7ce310f5985 100644 --- a/demo/Blackholio/client-godot/CircleController.cs +++ b/demo/Blackholio/client-godot/CircleController.cs @@ -3,131 +3,113 @@ public partial class CircleController : EntityController { - private static readonly Color[] ColorPalette = - [ - new(175 / 255.0f, 159 / 255.0f, 49 / 255.0f), - new(175 / 255.0f, 116 / 255.0f, 49 / 255.0f), - new(112 / 255.0f, 47 / 255.0f, 252 / 255.0f), - new(51 / 255.0f, 91 / 255.0f, 252 / 255.0f), - 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), - 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 _owner; - - public void Spawn(Circle circle, PlayerController owner) - { - SpawnEntity(circle.EntityId); - Color = ColorPalette[circle.PlayerId % ColorPalette.Length]; - - _owner = owner; - UpdateLabelText(); - } - - public override void _Ready() - { - base._Ready(); - UpdateLabelText(); - } - - public override void _Process(double delta) - { - base._Process(delta); - UpdateScreenLabelPosition(); - } - - public override void OnDelete(EventContext context) - { - if (IsInstanceValid(Label)) - { - Label.QueueFree(); - } - - base.OnDelete(context); - _owner?.OnCircleDeleted(this); - } - - private void UpdateScreenLabelPosition() - { - if (!IsInstanceValid(Label)) - { - return; - } - - Label.Size = Label.GetCombinedMinimumSize(); - var screenPosition = GetGlobalTransformWithCanvas().Origin; - var offset = new Vector2(0.0f, Radius + 8.0f); - Label.Position = screenPosition + offset - (Label.Size / 2.0f); - } - - private void UpdateLabelText() - { - if (!IsInstanceValid(Label) || _owner == null) - { - return; - } - - Label.Text = _owner.Username; - Label.Size = Label.GetCombinedMinimumSize(); - } + 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/EntityController.cs b/demo/Blackholio/client-godot/EntityController.cs index fd7ca37842b..12ee9057efe 100644 --- a/demo/Blackholio/client-godot/EntityController.cs +++ b/demo/Blackholio/client-godot/EntityController.cs @@ -1,50 +1,48 @@ using Godot; using SpacetimeDB.Types; -public partial class EntityController : Circle2D +public abstract partial class EntityController : Circle2D { - private const float LerpDurationSec = 0.1f; - - public int EntityId { get; protected set; } - - protected float LerpTime { get; set; } - protected Vector2 LerpStartPosition { get; set; } - protected Vector2 TargetPosition { get; set; } - protected float TargetRadius { get; set; } = 1; - - protected void SpawnEntity(int entityId) - { - EntityId = entityId; - - 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 newVal) - { - LerpTime = 0.0f; - LerpStartPosition = GlobalPosition; - TargetPosition = (Vector2)newVal.Position; - TargetRadius = MassToRadius(newVal.Mass); - } - - public virtual void OnDelete(EventContext context) - { - 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); - } - - public static float MassToRadius(int mass) => Mathf.Sqrt(mass); + 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; } = 1; + + 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 newVal) + { + LerpTime = 0.0f; + LerpStartPosition = GlobalPosition; + TargetPosition = (Vector2)newVal.Position; + TargetRadius = MassToRadius(newVal.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/Extensions.cs b/demo/Blackholio/client-godot/Extensions.cs index 98e43acb468..6060f78c13f 100644 --- a/demo/Blackholio/client-godot/Extensions.cs +++ b/demo/Blackholio/client-godot/Extensions.cs @@ -4,14 +4,7 @@ namespace SpacetimeDB.Types { public partial class DbVector2 { - public static implicit operator Vector2(DbVector2 vec) - { - return new Vector2(vec.X, vec.Y); - } - - public static implicit operator DbVector2(Vector2 vec) - { - return new DbVector2(vec.X, vec.Y); - } + public static implicit operator Vector2(DbVector2 vec) => new(vec.X, vec.Y); + public static implicit operator DbVector2(Vector2 vec) => new(vec.X, vec.Y); } -} +} \ No newline at end of file diff --git a/demo/Blackholio/client-godot/Food.tscn b/demo/Blackholio/client-godot/Food.tscn deleted file mode 100644 index b9055094665..00000000000 --- a/demo/Blackholio/client-godot/Food.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[gd_scene format=3 uid="uid://3gdkr0fla74a"] - -[ext_resource type="Script" uid="uid://dol6i5dumj0xt" path="res://FoodController.cs" id="1_lm7oo"] - -[node name="Food" type="Node2D" unique_id=2957058] -script = ExtResource("1_lm7oo") diff --git a/demo/Blackholio/client-godot/FoodController.cs b/demo/Blackholio/client-godot/FoodController.cs index 565eaa53f5c..2787e19dffc 100644 --- a/demo/Blackholio/client-godot/FoodController.cs +++ b/demo/Blackholio/client-godot/FoodController.cs @@ -13,9 +13,5 @@ public partial class FoodController : EntityController new(35 / 255.0f, 245 / 255.0f, 165 / 255.0f), ]; - public void Spawn(Food food) - { - SpawnEntity(food.EntityId); - Color = ColorPalette[EntityId % ColorPalette.Length]; - } + public FoodController(Food food) : base(food.EntityId, ColorPalette[food.EntityId % ColorPalette.Length]) { } } diff --git a/demo/Blackholio/client-godot/GameManager.cs b/demo/Blackholio/client-godot/GameManager.cs index 9509806380f..aa16a8bc967 100644 --- a/demo/Blackholio/client-godot/GameManager.cs +++ b/demo/Blackholio/client-godot/GameManager.cs @@ -1,219 +1,145 @@ using System; -using System.Collections.Generic; using Godot; using SpacetimeDB; using SpacetimeDB.Types; public partial class GameManager : Node2D { - private const string ServerUrl = "http://127.0.0.1:3000"; - private const string ModuleName = "blackholio"; - - public static event Action OnConnected; - public static event Action OnSubscriptionApplied; - - [Export] - private Color BackgroundColor { get; set; } = Colors.MidnightBlue; - - [Export] - private float BorderThickness { get; set; } = 2.0f; - - [Export] - private Color BorderColor { get; set; } = Colors.Goldenrod; - - [Export] - private string DefaultPlayerName { get; set; } = "3Blave"; - - public static GameManager Instance { get; private set; } - public static Identity LocalIdentity { get; private set; } - public static DbConnection Conn { get; private set; } - - public static Dictionary Entities { get; } = new(); - public static Dictionary Players { get; } = new(); - - private Instantiator _instantiator; - public Instantiator Instantiator => _instantiator ??= GetNode("Instantiator") ?? new Instantiator(); - - public GameManager() - { - var builder = DbConnection.Builder() - .OnConnect(HandleConnect) - .OnConnectError(HandleConnectError) - .OnDisconnect(HandleDisconnect) - .WithUri(ServerUrl) - .WithDatabaseName(ModuleName); - - AuthToken.Init(); - if (AuthToken.Token != string.Empty) - { - builder = builder.WithToken(AuthToken.Token); - } - - Conn = builder.Build(); - STDBUpdateManager.Add(Conn); - } - - public override void _EnterTree() - { - Instance = this; - } - - public override void _ExitTree() - { - if (Conn != null) - { - STDBUpdateManager.Remove(Conn, true); - Conn = null; - } - - Entities.Clear(); - Players.Clear(); - - if (Instance == this) - { - Instance = null; - } - } - - public static bool IsConnected() => Conn != null && Conn.IsActive; - - public void Disconnect() - { - if (Conn == null) - { - return; - } - - STDBUpdateManager.Remove(Conn, true); - Conn = null; - } - - private void HandleConnect(DbConnection conn, Identity identity, string token) - { - GD.Print("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(); - - 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(); - - 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); - - CameraController.WorldSize = worldSize; - } - - private void CircleOnInsert(EventContext context, Circle insertedValue) - { - var player = GetOrCreatePlayer(insertedValue.PlayerId); - var entityController = Instantiator.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(context); - } - } - - private void FoodOnInsert(EventContext context, Food insertedValue) - { - var entityController = Instantiator.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 = Instantiator.SpawnPlayer(player); - Players[playerId] = playerController; - } - - return playerController; - } + 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; + } + + private void HandleConnect(DbConnection conn, Identity identity, string token) + { + GD.Print("Connected."); + AuthToken.SaveToken(token); + LocalIdentity = identity; + + OnConnected?.Invoke(); + + AddChild(new Instantiator(conn)); + + 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/Instantiator.cs b/demo/Blackholio/client-godot/Instantiator.cs index 0b39102ac68..0fb88a01996 100644 --- a/demo/Blackholio/client-godot/Instantiator.cs +++ b/demo/Blackholio/client-godot/Instantiator.cs @@ -1,44 +1,141 @@ +using System.Collections.Generic; using Godot; using SpacetimeDB.Types; public partial class Instantiator : Node { - [Export] - public PackedScene CircleScene { get; set; } + private DbConnection _conn; + private DbConnection Conn + { + get => _conn; + set + { + if (value == _conn) return; - [Export] - public PackedScene FoodScene { get; set; } + 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; - [Export] - public PackedScene PlayerScene { get; set; } + 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 CircleController SpawnCircle(Circle circle, PlayerController owner) + public override void _ExitTree() { - var entityController = InstantiateNode(CircleScene, $"Circle - {circle.EntityId}"); - entityController.Spawn(circle, owner); - owner.OnCircleSpawned(entityController); - return entityController; + Conn = null; } - public FoodController SpawnFood(Food food) + private void CircleOnInsert(EventContext context, Circle insertedValue) { - var entityController = InstantiateNode(FoodScene, $"Food - {food.EntityId}"); - entityController.Spawn(food); - return entityController; + 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(); + } } - public PlayerController SpawnPlayer(Player player) + private void FoodOnInsert(EventContext context, Food insertedValue) { - var playerController = InstantiateNode(PlayerScene, $"PlayerController - {player.Name}"); - playerController.Initialize(player); + 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 T InstantiateNode(PackedScene scene, string nodeName) where T : Node, new() + private CircleController SpawnCircle(Circle circle, PlayerController owner) { - var node = scene?.Instantiate() ?? new T(); - node.Name = nodeName; - AddChild(node); - return node; + 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; } } diff --git a/demo/Blackholio/client-godot/Player.tscn b/demo/Blackholio/client-godot/Player.tscn deleted file mode 100644 index f5f6a37c044..00000000000 --- a/demo/Blackholio/client-godot/Player.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[gd_scene format=3 uid="uid://blicbcj7jt37k"] - -[ext_resource type="Script" uid="uid://d3l75ikpjh23o" path="res://PlayerController.cs" id="1_xhfnw"] - -[node name="Player" type="Node2D" unique_id=1748805711] -script = ExtResource("1_xhfnw") diff --git a/demo/Blackholio/client-godot/PlayerController.cs b/demo/Blackholio/client-godot/PlayerController.cs index 21a89c8d22d..9e1b6f04d9e 100644 --- a/demo/Blackholio/client-godot/PlayerController.cs +++ b/demo/Blackholio/client-godot/PlayerController.cs @@ -5,134 +5,120 @@ 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(); - - // Automated testing members. - private bool _testInputEnabled; - private Vector2 _testInput; - 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 void Initialize(Player player) - { - _playerId = player.PlayerId; - if (player.Identity == GameManager.LocalIdentity) - { - Local = this; - } - } - - public override void _ExitTree() - { - foreach (var circle in _ownedCircles.ToList()) - { - if (GodotObject.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() - { - return (int)_ownedCircles - .Select(circle => GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId)) - .Sum(entity => entity?.Mass ?? 0); - } - - public Vector2? CenterOfMass() - { - if (_ownedCircles.Count == 0) - { - return null; - } - - Vector2 totalPos = Vector2.Zero; - float 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; - } - - return totalMass > 0.0f ? totalPos / totalMass : null; - } - - 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); - - if (_testInputEnabled) - { - direction = _testInput; - } - - GameManager.Conn.Reducers.UpdatePlayerInput(direction); - } - - public void SetTestInput(Vector2 input) => _testInput = input; - public void EnableTestInput() => _testInputEnabled = true; + 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(); + + // Automated testing members. + 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); + } } diff --git a/demo/Blackholio/client-godot/main.tscn b/demo/Blackholio/client-godot/main.tscn index c9a65f667bd..95c012a83f5 100644 --- a/demo/Blackholio/client-godot/main.tscn +++ b/demo/Blackholio/client-godot/main.tscn @@ -1,20 +1,6 @@ [gd_scene format=3 uid="uid://cjb7808stemnn"] [ext_resource type="Script" uid="uid://p2e72osy6dx5" path="res://GameManager.cs" id="1_7mycd"] -[ext_resource type="Script" uid="uid://bm2c8ov8aaoi8" path="res://Instantiator.cs" id="2_7mycd"] -[ext_resource type="PackedScene" uid="uid://j74mpcyku2um" path="res://Circle.tscn" id="3_h2yge"] -[ext_resource type="PackedScene" uid="uid://3gdkr0fla74a" path="res://Food.tscn" id="4_1bvp3"] -[ext_resource type="PackedScene" uid="uid://blicbcj7jt37k" path="res://Player.tscn" id="5_lquwl"] -[ext_resource type="Script" uid="uid://b0l7e61svh6b4" path="res://CameraController.cs" id="6_7mycd"] [node name="Main" type="Node2D" unique_id=211175705] script = ExtResource("1_7mycd") - -[node name="Instantiator" type="Node2D" parent="." unique_id=182682719] -script = ExtResource("2_7mycd") -CircleScene = ExtResource("3_h2yge") -FoodScene = ExtResource("4_1bvp3") -PlayerScene = ExtResource("5_lquwl") - -[node name="Camera2D" type="Camera2D" parent="." unique_id=1030276943] -script = ExtResource("6_7mycd") diff --git a/demo/Blackholio/client-godot/project.godot b/demo/Blackholio/client-godot/project.godot index b66435415d8..d20f769b953 100644 --- a/demo/Blackholio/client-godot/project.godot +++ b/demo/Blackholio/client-godot/project.godot @@ -10,14 +10,14 @@ config_version=5 [application] -config/name="client-godot" +config/name="blackholio" run/main_scene="uid://cjb7808stemnn" -config/features=PackedStringArray("4.6", "Forward Plus") +config/features=PackedStringArray("4.6", "C#", "GL Compatibility") config/icon="res://icon.svg" [dotnet] -project/assembly_name="client-godot" +project/assembly_name="blackholio" [physics] @@ -26,3 +26,5 @@ project/assembly_name="client-godot" [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/00500-godot-tutorial/00400-part-3.md b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00400-part-3.md index 4c04d9f9c4d..22c85f7bed4 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 @@ -1320,23 +1320,34 @@ public partial class PlayerController : Node .Select(circle => GameManager.Conn.Db.Entity.EntityId.Find(circle.EntityId)) .Sum(entity => entity?.Mass ?? 0); - public Vector2? CenterOfMass() - { - if (_ownedCircles.Count == 0) return null; + 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; + 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; - } + totalPos += circle.GlobalPosition * entity.Mass; + totalMass += entity.Mass; + } - return totalMass > 0.0f ? totalPos / totalMass : null; - } + if (totalMass <= 0) + { + centerOfMass = Vector2.Zero; + return false; + } + + centerOfMass = totalPos / totalMass; + return true; + } public override void _Process(double delta) { @@ -1371,99 +1382,65 @@ public partial class PlayerController : Node } ``` -Let's also add a new `Instantiator.cs` script which we can use as a factory for instancing nodes. Replace the contents of the file with: +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: ```cs +using System.Collections.Generic; using Godot; using SpacetimeDB.Types; public partial class Instantiator : Node { - public CircleController SpawnCircle(Circle circle, PlayerController owner) + private DbConnection _conn; + private DbConnection Conn { - var entityController = new CircleController(circle, owner) + get => _conn; + set { - Name = $"Circle - {circle.EntityId}", - }; - - AddChild(entityController); - - return entityController; - } + if (value == _conn) return; - public FoodController SpawnFood(Food food) - { - var entityController = new FoodController(food) - { - Name = $"Food - {food.EntityId}", - }; - - AddChild(entityController); - - return entityController; - } + 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; - public PlayerController SpawnPlayer(Player player) + 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) { - var playerController = new PlayerController(player) - { - Name = $"Player - {player.Name}" - }; - - AddChild(playerController); - - return playerController; + Conn = conn; } -} -``` - -In the Scene dock, add a child `Node2D` to `Main`, name it "Instantiator" and add the `Instantiator` script to it. Save the Main scene. - -### 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. - -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; } - - 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) + public override void _ExitTree() { - GD.Print("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(); - - Conn.SubscriptionBuilder() - .OnApplied(HandleSubscriptionApplied) - .SubscribeToAllTables(); + GD.PrintErr("Instantiator Exit Tree"); + Conn = null; } -``` - -Next add the following implementations for those callbacks to the `GameManager` class. -```cs private void CircleOnInsert(EventContext context, Circle insertedValue) { var player = GetOrCreatePlayer(insertedValue.PlayerId); - var entityController = Instantiator.SpawnCircle(insertedValue, player); + var entityController = SpawnCircle(insertedValue, player); Entities[insertedValue.EntityId] = entityController; } @@ -1485,7 +1462,7 @@ Next add the following implementations for those callbacks to the `GameManager` private void FoodOnInsert(EventContext context, Food insertedValue) { - var entityController = Instantiator.SpawnFood(insertedValue); + var entityController = SpawnFood(insertedValue); Entities[insertedValue.EntityId] = entityController; } @@ -1507,12 +1484,77 @@ Next add the following implementations for those callbacks to the `GameManager` if (!Players.TryGetValue(playerId, out var playerController)) { var player = Conn.Db.Player.PlayerId.Find(playerId); - playerController = Instantiator.SpawnPlayer(player); + 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; + } +} +``` + +// TODO: ------------------------------ +Explain subscriptions stuff. +// TODO: ------------------------------ + +### 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. + +```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; + + OnConnected?.Invoke(); + + AddChild(new Instantiator(conn)); + + Conn.SubscriptionBuilder() + .OnApplied(HandleSubscriptionApplied) + .SubscribeToAllTables(); + } ``` ### Camera Controller @@ -1524,56 +1566,54 @@ using Godot; public partial class CameraController : Camera2D { - public static float WorldSize { get; set; } - - [Export] - public float BaseVisibleRadius { get; set; } = 50.0f; + [Export] + public float BaseVisibleRadius { get; set; } = 50.0f; - [Export] - public float FollowLerpSpeed { get; set; } = 8.0f; + [Export] + public float FollowLerpSpeed { get; set; } = 8.0f; - [Export] - public float ZoomLerpSpeed { get; set; } = 2.0f; + [Export] + public float ZoomLerpSpeed { get; set; } = 2.0f; + + private float WorldSize { get; } - public override void _Process(double delta) - { - var arenaCenter = new Vector2(WorldSize / 2.0f, WorldSize / 2.0f); - var targetPosition = arenaCenter; + public CameraController(float worldSize) + { + WorldSize = worldSize; + } - if (PlayerController.Local != null && GameManager.IsConnected()) - { - var centerOfMass = PlayerController.Local.CenterOfMass(); - if (centerOfMass.HasValue) - { - targetPosition = centerOfMass.Value; - } - } + 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); + GlobalPosition = GlobalPosition.Lerp(targetPosition, (float)delta * FollowLerpSpeed); - if (PlayerController.Local == null) - { - return; - } + 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); - } + var targetCameraSize = CalculateCameraSize(PlayerController.Local); + var desiredZoom = Vector2.One * (BaseVisibleRadius / Mathf.Max(targetCameraSize, 1.0f)); + Zoom = Zoom.Lerp(desiredZoom, (float)delta * ZoomLerpSpeed); + } - private float CalculateCameraSize(PlayerController player) - { - return 10.0f - + Mathf.Min(10.0f, player.TotalMass() / 5.0f) - + Mathf.Min(player.NumberOfOwnedCircles - 1, 1) * 30.0f; - } + private static float CalculateCameraSize(PlayerController player) => 10.0f + + Mathf.Min(10.0f, player.TotalMass() / 5.0f) + + Mathf.Min(player.NumberOfOwnedCircles - 1, 1) * 30.0f; } - ``` -Add a `Camera2D` child node to the Main scene and add the `CameraController` script to it. - -Lastly modify the `GameManager.SetupArena` method to set the `WorldSize` on the `CameraController`. +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) @@ -1603,7 +1643,7 @@ Lastly modify the `GameManager.SetupArena` method to set the `WorldSize` on the }); AddChild(background, @internal: InternalMode.Front); - CameraController.WorldSize = worldSize; + AddChild(new CameraController(worldSize)); } ``` @@ -1612,7 +1652,7 @@ Lastly modify the `GameManager.SetupArena` method to set the `WorldSize` on the At this point, you may need to regenerate your bindings the following command from the `blackholio/spacetimedb` directory. ```sh -spacetime generate --lang csharp --out-dir ../Assets/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". @@ -1638,12 +1678,6 @@ At this point, after publishing our module we can press the play button to see t ![Player on screen](/images/Godot/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 Godot project Assets folder called `module_bindings` 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 5ca13a1b85c..01517643764 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 @@ -1,6 +1,6 @@ --- title: 4 - Moving and Colliding -slug: /tutorials/unity/part-4 +slug: /tutorials/Godot/part-4 --- import Tabs from '@theme/Tabs'; @@ -476,7 +476,7 @@ SPACETIMEDB_REDUCER(move_all_players, ReducerContext ctx, MoveAllPlayersTimer _t -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 Unity. 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. +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. @@ -527,51 +527,44 @@ 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: +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: ```cs -public void Update() -{ - if (!IsLocalPlayer || NumberOfOwnedCircles == 0) - { - return; - } - - if (Input.GetKeyDown(KeyCode.Q)) - { - if (LockInputPosition.HasValue) - { - LockInputPosition = null; - } - else - { - LockInputPosition = (Vector2)Input.mousePosition; - } - } - - // Throttled input requests - if (Time.time - LastMovementSendTimestamp >= SEND_UPDATES_FREQUENCY) - { - LastMovementSendTimestamp = Time.time; - - var mousePosition = LockInputPosition ?? (Vector2)Input.mousePosition; - var screenSize = new Vector2 - { - x = Screen.width, - y = Screen.height, - }; - var centerOfScreen = screenSize / 2; - - var direction = (mousePosition - centerOfScreen) / (screenSize.y / 3); - if (testInputEnabled) { direction = testInput; } - GameManager.Conn.Reducers.UpdatePlayerInput(direction); - } -} +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. @@ -856,33 +849,18 @@ Notice that the food automatically respawns as you vaccuum them up. This is beca - Publish to Maincloud `spacetime publish --server maincloud --delete-data` - `` This name should be unique and cannot contain any special characters other than internal hyphens (`-`). -- Update the URL in the Unity project to: `https://maincloud.spacetimedb.com` -- Update the module name in the Unity project to ``. +- 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` - Your `GameManager.cs` should look something like this: -```csharp -const string SERVER_URL = "https://maincloud.spacetimedb.com"; -const string MODULE_NAME = ""; - -... - -private void Start() -{ - // Clear cached connection data to ensure proper connection - PlayerPrefs.DeleteAll(); - - // Continue with initialization -} -``` - To delete your Maincloud database, you can run: `spacetime delete --server maincloud ` # Conclusion - So far you've learned how to configure a new Unity project to work with + 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` @@ -890,8 +868,8 @@ 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 + + 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 @@ -899,18 +877,18 @@ 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 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 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! +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 us from connecting multiple clients to the same server. +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 -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. // --> This errors because they use the same AuthToken There's still plenty more we can do to build this into a proper game though. For example, you might want to also add @@ -921,8 +899,4 @@ There's still plenty more we can do to build this into a proper game though. For - Nice shaders - Space theme! -Fortunately, we've done that for you! If you'd like to check out the completed tutorial game, with these additional features, you can download it on GitHub: - -[https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio) - If you have any suggestions or comments on the tutorial, either [open an issue](https://github.com/clockworklabs/SpacetimeDB/issues/new), or join our Discord ([https://discord.gg/SpacetimeDB](https://discord.gg/SpacetimeDB)) and chat with us! From 913004235559bf02c5d257b4df99e389c07994f6 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Tue, 28 Apr 2026 09:42:32 -0500 Subject: [PATCH 06/12] Fix and polish part 1 and part 2 of the Godot tutorial --- .../00500-godot-tutorial/00200-part-1.md | 7 +- .../00500-godot-tutorial/00300-part-2.md | 197 ++++++++++-------- 2 files changed, 109 insertions(+), 95 deletions(-) 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 index 2a44911a003..81ed0bd5402 100644 --- 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 @@ -25,6 +25,7 @@ SpacetimeDB supports Godot version `4.6.2` or later. See [the overview](.) for m 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. @@ -52,10 +53,10 @@ 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. -### Add the GameManager to the Scene +### Create a new Scene 1. **Create a 2D Scene**: - - In the **Scene** (usually on the top left), click on **Create Root Node: -> 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) @@ -73,7 +74,7 @@ You will see a warning saying `This inspector might be out of date. Please build - Alternatively, go to `Scene -> Save Scene As`. 5. **Run Project and build C# Project**: - - Press `F5` or (on Mac) to run the project, click on `Select Current` to select the current scene as the main one. + - 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! 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 index 54aaa226f62..d317958065a 100644 --- 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 @@ -45,7 +45,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 Godot 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 Godot project `blackholio` directory. Inside, there's a `spacetimedb` folder that contains the SpacetimeDB server project with C# as the programming language. @@ -55,7 +57,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 Godot 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 Godot project `blackholio` directory. Inside, there's a `spacetimedb` folder that contains the SpacetimeDB server project with Rust as the programming language. @@ -67,7 +71,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 Godot 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 Godot project `blackholio` directory. Inside, there's a `spacetimedb` folder that contains the SpacetimeDB server project with C++ as the programming language. @@ -75,21 +81,21 @@ This command creates a new folder named `spacetimedb` inside of your Godot 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.** @@ -221,7 +227,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; @@ -256,7 +262,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)] @@ -371,7 +377,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. @@ -504,7 +510,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. @@ -526,23 +532,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 ``` - @@ -568,7 +571,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)] @@ -638,25 +641,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 Godot 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 ../module_bindings +spacetime generate --lang csharp --out-dir ../../module_bindings ``` -This will generate a set of files in the `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 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 @@ -676,7 +667,7 @@ This will generate a set of files in the `module_bindings` directory which conta └── SpacetimeDBClient.g.cs ``` -This will also generate a file in the `module_bindings/SpacetimeDBClient.g.cs` directory with a type aware `DbConnection` class. We will use this class to connect to your database from Godot. +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 @@ -693,69 +684,76 @@ using Godot; Replace the implementation of the `GameManager` class with the following. ```cs -public class GameManager : MonoBehaviour +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"; + 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 Color BackgroundColor { get; set; } = Colors.MidnightBlue; - [Export] - private float BorderThickness { get; set; } = 5.0f; + [Export] + private float BorderThickness { get; set; } = 5.0f; - [Export] - private Color BorderColor { get; set; } = Colors.Goldenrod; + [Export] + private Color BorderColor { get; set; } = Colors.Goldenrod; - [Export] - private string DefaultPlayerName { get; set; } = "3Blave"; + [Export] + private string DefaultPlayerName { get; set; } = "3Blave"; - private static GameManager Instance { get; private set; } + 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); - - AuthToken.Init(); - if (AuthToken.Token != string.Empty) - { - builder = builder.WithToken(AuthToken.Token); - } - - Conn = builder.Build(); - STDBUpdateManager.Add(Conn); + .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 override void _EnterTree() - { - Instance = this; - } - public override void _ExitTree() - { - Disconnect(); + public static bool IsConnected() => Conn != null && Conn.IsActive; - if (Instance == this) - { - Instance = null; - } - } + 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) + private void HandleConnect(DbConnection conn, Identity identity, string token) { GD.Print("Connected."); AuthToken.SaveToken(token); @@ -779,7 +777,7 @@ public class GameManager : MonoBehaviour GD.Print("Disconnected."); if (ex != null) { - GD.PrintException(ex); + GD.PrintErr(ex); } } @@ -788,19 +786,12 @@ public class GameManager : MonoBehaviour GD.Print("Subscription applied!"); OnSubscriptionApplied?.Invoke(); } - - public static bool IsConnected() => Conn != null && Conn.IsActive; - - - public void Disconnect() - { - STDBUpdateManager.Remove(Conn, true); - Conn = null; - } } ``` -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 pass 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. +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. @@ -812,7 +803,29 @@ The "SDK client cache" is a client-side view of the database defined by the supp --- -Now we're ready to connect the client and server. Press the play button in Godot. +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. From b50d5e9cd191555d9c6d683878eb3bad12753962 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Tue, 28 Apr 2026 12:52:36 -0500 Subject: [PATCH 07/12] Fix and polish part 3 and part 4 of the Godot tutorial --- .../00500-godot-tutorial/00400-part-3.md | 396 ++++++++---------- .../00500-godot-tutorial/00500-part-4.md | 181 ++++---- .../images/godot/part-3-player-on-screen.png | Bin 0 -> 33781 bytes 3 files changed, 265 insertions(+), 312 deletions(-) create mode 100644 docs/static/images/godot/part-3-player-on-screen.png 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 22c85f7bed4..96f4b7e94b5 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 01517643764..e910bfe03fc 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; - - 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); - } +{ + 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. @@ -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 0000000000000000000000000000000000000000..6e6719a69a455d8d450180bbfd3ad07edfb87737 GIT binary patch literal 33781 zcmcG$WmuHm7d|>PN{4hQh=`P=bP6blfGE-+DUEb@sH8|qgMf&%bhk=NNq4t&pEd9M zd;jO0kLSagYxL5YXXbgHz1LprUiW?Pd8@1>hj*RoIs$>fdn7NTia?;pBM_)7*qHDN zZ}w9K1cCwaNJdi4Wo%>8)#I6Cz4)biIsfYq{5roes+Ctz*_G4S*_BzYmtd2j*ip6# z3x$WfnA$f{CY`u#hq@8P<>@Z2U97s?aCDHZI z2MtkDT6*8KhbFk=wY@#h;pTX7K>Kl)%i=)0(hq{81-&qRjYOE^Hp9e|@<5vxm!If3+^#~`| zdz~NKeT<1Y5TuWi57^&EMsUUmPJm}ocCh~nVjfIGT*9pUBW6)#{zXT+T_u>CbZ zzs(m7OP`B^rrzspvqCyuIjy#qCO0*+}L?JTH{U^onUli<6TR{!Ru$?8ulH z40?L{!I2TekpdljanpNl7t+;j<{C-6%C)VxH$d%DEd@8d?4rP(W$3w zW`EXVN%p|1?f!~N;%n{%+((ZdT?l#KzJ1%EEB-!Lf+F&oA3eG@Ha0f1xf!;zWBX;u z{P^_r)#1kIpBm@w$jJg!Uqq>CZ;%>mpl+cY9J;o));BbSk)Rm#^3-+^Y z#ilCzWj7tN;+LU+{=6h5Bg-f+CzH)2g2@Vb|K4AosTrp#@@bk()l&-C&|%*6!Z7KTbU_ytDb|&j+ADJZmzBx*7d?dLXmNC!R_s` z6u6l3OvC|!fj7m(=u~q_b8~Z@$oo=01?NPlJG-u(FK+D$eLQ4G$SWzy+S+m{GLt-3 zP_ToTGb__%A-&$((ZTrO0R_n&r2yGXJC}jwTtlAQw=v){k%M7oCZVB0`CXHJ;f)}( zVmzWsI~;D(jF(Eg)}?=yyS1$?y{d{5!UcxSUn;#3?rCOy9Y;@3PcebJZOgg;*|PG} z4f0_t6bJFN@+sz}atZGe42Cu($_T z=$;%Wb4yZNo95lScea~jd=z1_=$M$AV>~x+HobOt`y|ii+i&*$g&(CSRl=5FlGe>JoO;1}msb^(pk0o8fC9roqR#e0#y^hfyMjgDcV6?Ef*fc#& zkLi!X&dv^_!$pA$2@<*VzJC2$WHG`qI6QodmzUIe!VU|al9H0y6st@s-A9|F;v{Ng zWkmv(!r1tDe}Dh%{wgcc{~mY**8@=@!z$+uvE04utIYDqss5qEl7*^QW(nAky(1WMtgx zc1uS`=SzP6Lp!_s5IRD_!s#RHR_1)4YIfejDKEQ4B$fI(^KJX$iELWGjnruDOe+Zetya-DkyNO@8#uVgV#tP`*axf=yRKLl$DjGr=|I@ z%gg-lK?dhW_#7TOsi~>G%*N?%x*aHGK*>6Kiud0UG6-cYEu83|=#ZO`L6MDvBEdr1 z40!;d{QP;Fo5Ftw8p-7iWk7&J_JDwnjamvoFvNAm@-KlhvU zjgDSJ>>M3o?HG~^=BiyseEIgx=-z)vs1!nhK|$2l*CP<=6%_&jQa2rhXlvU4&- z)z!#}GL8#?N`>$V563UZ|L@%@nVbQX6^=S4-UynJ;o!@qj{{w59`7Gmdx+|1h6Rqv8$6Xj7erx(8;&z5g_ zIWC^5v&1voP_1|W%~%LGRy^EVfd7Rt$}{qpehP>g>NtEG@v=o58*x65`QPl`3PUP8 zsc-I85dZJY{aiOO5z#++&Gh=5Hm`q9zaQIxfqJu9-3AdVu0-hcv~*ZktA}0sf0M$i z7=m>{y?>~)-75Cg%RA_QC!V-N{5JB#2R}$JAu_irmJGNklwZ6EdX}Tqq|a5Pa#rLq zy1CNlGzT>f(1CTg^sOlUsoJW@$63cqi8jF-)o$P3C-rIJw;fZ)HauZwU@#U$&1T;? zszuG24D$IXte^byc0>w(dPS1^oqw-8idt1wmCNhgrNUo9!a36X{CHRX(IW%@>NM%` zw`q+MMkXdwx1#pX_K4r2CnO|bU&p1|ZyLq?#Y3qmMbyr5#qQC}oHlQMzkv1RG&Rl(bRlr*b$ERbGx>` zGd?9Hd}YN9%0KbLhYv-x{+&!b5)~B{gbx}P{>)gJg>^SFLGLYfORK4ohSQ7p$Zoa( zn2AhCc-!cMfUA6Ub$P*I%AMe^;ERIt#l{SV2rGEM+Ks7^^g|Vym;ng_QPOuUM6<4m z5oejliTIYf9)aplsXGh~m;RNf7r_@P|3EF(QRnUfGu|(Gc{K(opQFrA{w_xTGTR5&!IFbp;hbjycUiy-oYbP{)wpCL&vb{;!Wp6sojfI)` z>C?`n^K{ks?ZX>`3XdQAY32v1<_4&-mMPkpoBPRTHpSdiw_W^o10eyZ3!>ikc*mfV z_n+{m8tqC@0K{QmQ}e>uIIFw%i!)@ev!m^8=g3@B+gx$q+(hF>6z0)*moLQ2lSLSZ z3i0ok&NElO)GiD!!M1z8Yf_-MZ_{%o<@`Atn`|p1~%m<*Wm@ISy>Iouk$eg|NRoxU67GgyqhZVwD zPQ7p_3cURMLRMJ+E?TPiOU#5IL)&XR5BQ|E>;bwZ)FqAZ525vY6PF&n)EDrb zpR=+gvs7=wc)~d+&L)ch{8FEK{`~n9koN=orBH=&N`#b@6d^5bSV#yqT%Sais-Vu+ z@bIt~R-kozz40YJ;EJ_1b7UFj5Jpx`h-qaOQvLx>o$7$WK{Y03W^Vt8f7X^6VHK4T zRYBA3Mzn|ZVPh}UHcw`P*lmYW2=oWb-7LM-3$!I&T?G+j!f?tzeMkZ(kPZL}kpYr_24NBrUfqd8F$^K#kHT!RO5(@mUMoHc3U}D?azLy z&5W6yr~@V=LjQ?56FTaQ&7Eq0bmAyNi|pgMS?`m(pFVx!GH6DxEj_XN{1m&nKciYw z;{-w(5TIoKizC63c)mSs{b9!FyD^Uy@@WxHFwdHTn)zg9Ws^<(-rn9aa&kwM`k$ct zNo~5lCvOd9AA%hgr`*ez6x=*Kw8K>JrWlM3LYw}zVy>E0x-tVlKN&zHrG`9ls|owM z12<6sV{in-CnkJl9FOfSM#h_D?>FDM)MOSH6S2lzmz0#GBjBp4v8iGOB!*x+>z9BP zf4}jc;v?`xFZ01sTxRf;jP#RU1`;0wT5VL!yN$dY^5GK`BBbPTFRk@^Q$)fU9-@Cu ztdX11j3LHHDZq;N`#AN$c`p98t+KXuC;-S%oc%pBpWNl23WXz#oScQzI-!6UqY@J> zNIdRirL;%T+mPs&6vRAx`O+-fE<=qqDk*7V-8lhGvJN8-az)>tKW2N?s=2KnZY!`Q zJR4q*|M1~e!t&4PJ65O+kZqvc#P-q(IsC{k=PyjmJizq%{Fw+Nz>V>+Fcd_TU zw+Rpc&nb55`+onP+vkgUytp6SGfEJfbI7h9Zz1HGLEGh9-qf}5Z)(bjdSc0aB)*q6 z_vyvNeQ{#wd}wKD0S01+h01P5t+!QSh>ATM&v3O9b$sqJGq?Zl?$S_}%j{2s z;509z0usO)L#}IGM@40JU-bF&=lE#)p1U$dvK(sL8n+d*<^)g8oa)K+&ce4zK38W#rY*@`fl;71fCM z&tJ6$Dz&6vlvS!ui>=`4_b_Nyy}7N%dIMI=85FiR>8 zwjkNeRdwZ7Z9=WQp=vd(+~VTm>Gi1*Q)npCU*uuYxU6iJLak^@60~>2^La4$_LJ;V z+6y_kj>yr9)6S8Rk=gx}Nh;CF!sLG;-DzP(|C}4yC7rb-b9#;mQ_=c9rgpAP$7&~D zpV?;3jFDb-(1yauhRv@sx1{=s8Lmnuiq;@5as{VYqF1yZGn z7QVXrh)x_E3rmQp**_~G!yv6=aE!eGqvv<&W*7b;o{p3 zRd~=#pa9qMy&#h3qPy?4mDNK@NtEBee@8|}A`o<(WL6yUo1s;m(-Lx)>-jDYMn<2F zJu^Th2(c|&7$ zWF#c>J3LUyp+-0{Rvj$`ByoAMF6bvVxB?}R{$AkFkeFTJHNPqvNnmY z3!ShOARG~qiB%CG)C^qUZ;lmwz?OyS?)@MWsBl%2DuhDRMXkX3lD<7Cs7O)~etn_wV7Q zsj(K@&Pv6VYIJGP85kJwPkx8{;If;O2~NGs#)gi7(x!p>aClnW`_dz4S{U z3ZcA3#l{AG{74$CgLO7$+Ld6*w(|s;?08EO6e`fMuxdmjiVZu)y575uOwW9{-Oi0$ z)9DJ$P;pB+_4E8}67k+7AC3ExeP#!J4l4|#*OmLt^pvYIiqW33Ctzpxqh~3iW~t$d zC!lj$^_E_QA7-O)E(7(-e#K94o4~;|Zl5i5Lta;x4q4T%(Z%y5z>Fhv^wbRbjT@32 zag4yYK>ni;_o`jgS7RY1eD6MuyYww9AOIauXhvC?V`E!J1g$V;uG+hzBCZLy^?d%x znXXPX9i1?MmQ@$D&_#4a(93CRQo$fVx-)pqX+I7lM+1dpam#vXBd^|^K*Pp#@9_SB zdD#QcQ-?yUFUl+%O9tKjA+{Hj6@UN!B_t&D0j%8G-hPXpf4GG9+tGWNuAI(K$;n|r z5kuY4T1xLXV>^pGa1*vL43wIUQaSk-ADi6+mIWZ=eVZwfh_3gi{5De)URbcYOt`xf z1qj@awrES%*NPF(22MP&6?HMwKMRwr%B#?Bxh->fWW8d^B4#n3yec#=A>g`Ll(^rl z#%=ap$V_Oz&&<)Re=U7TCnAK(G1=@_H{H&g!*7AWGyrQJJTajiXH#;tJsknb7)VBF zM1YY4@^lW^SS7i2D1ee$TGYTjVFA`r*hvHa5K4xpSB_&OR9QR(CXLaro*r3MRpLSO zvMlO2U?U9;836$UqCm5Q#>&CT2{{(Ob(frgc#%_5Qflz0Ug|$ha@@B6o?NEMPDM@K z|L4z&s(^Iy%Zcr3jjau>V0>CBMa59#2CiOd;7;J2Ku2!ybgscg0lD+g((+qYcJu)^ z&^Q?x8O;Vks!~#@t*>^|$c648^J_+8ul6TKR@UpcqJHGN0yl}|>GPO(&6r~ztElS* zLr+^h)YeU7^a;t$t8U+{EAVsOTS^ci`i4Mg7~Q%Rdx41>^(PbOHTQ~2@de)ZxaBw7 zOs%c0419by78e&ejFY4672-Gofou$?6^?I1t^{~okkQfMH~RC%Bhuy{b8uX3K3(~8 zcXvX>;<3u%kvLDK3>l;=V53U%{2yKbghvDL3~&T9?%!HvCLseY_Z21rNM^c(^z=-O zZda^66&+k(dF;0~$_+f9LWS5h6`szIrE@Af?{9N`7qC}|!UIeLd<9TY8J|A~;LQaA zbKhdbJqxi!BN_hhH&48x3s4Ywsn@6Lg|fLwj_^egKd10$ZXTKV*~CWk~&1zImZKU9TT2lX}9en%h_q5XQ`am-cv-$gfyX!`$0 zwS~w=Nai;EI~eeHCIOn_fB#t^8;H0aPNDxV5`;NIG4T06AHxhlVgBz8DniWof3G?u z#7yG<{|~e-c>|Ct$NU%J5D^_v^Zb83f4ZIo*N5LWh6?}DU{PAF*Laqi71ml}8UDMk z3hcI5*R*{9yR`{swpv?MH=R3Le*fpCTlJbMet9=;TVDI`LvL{8q8R`8pa0j0O_*>e zFqrTV5>1K#GXy{ycv;|q39nNg(f$aFzCrC5(tstBN<`FO)Pejmye0UuT(w_833s_nwT3M>Q8Hp~^vaQo-m&n`TUFhL#J*<1Z z?9Gqz-z4Lq|KE|x%Ye)E-|PK8MVS8~DK*}%c-1wN4lCTk#F1xqtFHbE72M3Gn zLNKo(KhF5^5gTT}t;V$AMtyyMLKqLA4@IurUvoE^@s`-h0p=nUIhwHF+1)KxR8UfC z2gPXjw*u$=A2Pi1LC<~ea&Zw3WIt(H_!&(^LxT&1Iy61J0s`~Ew>|K_6nXRJ4N}=; zSCGjJZ_>lt{JgpnnuvQ5Z${xsO2|Lle3aPBQ0EYiWTacB+_&qNcU`H%WJOL_GA$>F zj1Ja@_?vF?fLH>Z`W%p8#*D5%{w{VF8~r5Sn5ZHdFSj!E4}H^etC5s~BIMgQwysZn zowG$bO-)VM`1tK`QFgteM>8w^na9iGS8s!YFs%)4QSAz4&ZRwRz(|g=<2uE7ne9$~ zju_IL_LUgHNLLPdJ&=9#{(Yj$%gddEgNL4;qR>Y`L;4;#3w|aT<1lP!-IVkafi0o_ zA-z?*r^KdLPlK=pHS~x|pBNh*1P#mYMAkpB;y*-3@9wk^w2Y6BXC2`LN+I=2;HHje z{0VFiH#Dq`Gj1}2#sx3Q%gZ-HVMteIVH*v(apT4V*S}0XDWW*}FFM=T0woJ%Or@?1 zW!`CC$SHB{&YQvdB6h}!z3--&^neH{k^o$^nwp=V_lLsWl_E+FY}oPPxDAx)KSh}z z<3vckW?%2%C8F54QZW6MaGBi0uU;!#`L-r8u@kwqgZUU~s ztNW;RwFtxkNEW|WZL2fUI>yIrpX3-ChH<^ypO(_o^Xd6r$F+Lm{%8a=D+55La0e1D zE)PIBL_vV6^X1DI34l6)P5qaamWakN>oB+}7ItvgXNVD?vgK@FOm2H&y_NA(*YmtV zOIsUG@iWN5=k@MfTjhE|4Jf9k7a9O_9M^|u-*eVCrii*%X1xvafu`}Bb`>#5bNHo1 zXsD=2umWURMrkRDX-`tq)y4GHed=d7o5lX~0`#Nk=(AKaGc_)dn;m^hE5rE8IUOuf z*ntd590oD^Y*j zVw{_NB|7;dGq_nGaT5D#g-U4DML;F(WvKTx+*VXQz2K2Yv#NKSvsAm(Sa;)_d?u;z z$_Ga>eGmS~;(BgAWLXwLbN$8Hr%_8ydvX0-YNExv{yqw)gS(4TD-n0Halw;`Y6}anI6E!87Wo5rwc5+g8Js zSMAm1nPp>?hmN2cIh&y1sDW*6Mg}St0i6uY1V9uN1OzoQ(rR7zxSTfrtP5zMqHI+y z(}65aaH5#q*eC)0LuOtc4&Vc8@)R6gTp4Zcq}%g(FuWKT7+(qt=~aXaA|eR9F85Lm zp{s~}-EetOD0<4&4t@t5GPXvDaAX<)H$io^P?D%S87RGU{ZBnHr2&%;7wABaU3+uZ z`-u++=(>&$dhbxsON)rQ1#Wm@=PT!Vh4oUv z?<~Bj>fQ&tgvZ6fh=?yPSEWM-t@Hjm553p%O|PSx>6@aWw5Gi&ZQv#ujb!}D!pgcf zjYzAj^Zdc4rmReu;_>%UNIpjrC~smX3nZ4KB^ba7n^{?b79R`%_+RZ9X#;^|4vk6E z8|mAG*jyl4(1^M@$U9dg@LN&U*451}E@G5v;uXKNyp~qQL%&W>5c7CwJ#f;N1SogY zR8KN66ci=Y8+PD3IJqsobpwvl>*{Qp5pXw54U~N-+@J)4k}nP71#o{F%2N;Dld(7Z z6Ns7FE7m-QF@Wp>8mo+*-R={u;FM(obu?qqyD`5|*n=N|cHK+QoJ$a@N-wd7mf3|B z`Pks&sf0dn%DtAm$2IG32A61me1S zGo^nv2jc9W@AVo&XO5J?Fdclo7)v|+=1K??4oPKhE#KYD$nBt+?sB`i~AbQh>%#%}Eg2$bxSsSppC>cJSN@vvTTF8FJ_H`Z4DE{77GiD7J%Zj%gb0$*;gEd78Vv}>W-$Ue~)aZ2sKQ6oB2>WU!0qJ4WxYdC^C&+ zUYyxHmazX)v2Dv7?I`|@<^!dohK2^eByqA*@YQ+mm5ijMud!xt4t|8lUu=MQ;B-=f z26p=Lup&14n;L5yt@DI$g!rY)59`R3r_{|YEl?FB6BBWC#MhV~xS7wcMTgGn!vG%t zeCmZ7C65lqgnQ}*`k*?}UEl{w`QAzOAO?;QVY@XU2-n-F&&2?Sj@8vwK>f!Dh2CM} z7pqUd6cj8`W|8xl_Ot@5uo9@NuBJj}N-r-lBv$~cf-(!2)fg`T3LIGDu}9a@QNL-F zVnR;H6a%a$$NxcnN&|aeNJdt++S8Kh&YgK^RyZ22#%&tfq8R1a1Qd^^+&K|5gSpT6 zM>99<#T|}=att;?J2t9Y9eK$z3(T;1hfLE4_z`;Rv$QK1)2qJNxr+(y z&4j2{s25MgPCidct0OAi4p>3ci8w#^P>^_-|AG-_Yj3%?!kvrP_!sFny#~6%`qQ<@ z$08IV;4}y)b4K%57=~3xfJ6_vt{A{(BYY-noDJb>0QJd29)Xw|{leH6iNghD3o_xP z$r$7!6B6w;1(;y5t8;siokq0S*U$<=g*_|!x|pGwixkaq$T^C3IDBp^#J;rFhUnjg z(qQ=O<6V$K^0lje6Klh2i72v}7VEUY!l!ARZ3*5TeD+k5Ioe=ztnAPC*Cf46N?;V3 zZNj4eo-jN#6zC0=66{We-C>gI>g1Z5n&7Jlq!o73|M}teOBh!faj`6NY;>2MCM=bg zFIQeTP0QaLr0yHvqkA-DjA*R1o1eH0N1CV9SmSEawJU9#mT0WSlsbR?vR&!BNrx@H z89aT5P^ENa96b*Al;dNn7uJ*8?v|xe`x0_lbhI&>m4{C-{K$eCPj??+GmS)aUh8F^ z4Nj%^D|oF>c^~SkI7oir(b{Ro;rxTXeY7#^FY10cvzn6{1lEkL>UECV-FE7_o^8O; zN;V&Ws@Un1CNP+&w2OxV06el7M4Yj_O@%};#k#ZlG&ePYQCygF z`KXR-B?%1YLX_Nwp91jOdY%V2poSVce*o0BKagT&Wu2&&6ttZ|SXV8QDLi3@E&^nxMExdPr1cFdaAsN>0^}yzA>lk%^iW7T zGZ#nGa$sZ1R!o@N-Hr#n4lx685C13*xHikl>Kl;JWPlI?tC;Px8f!dbD2XMHMER(~ zTmZ1_wh)N#p9*I7huAb+ki~K8wcB{@QFjVTvc}By2yeFr(|bK|<75eQYg{=`Vz=J# zdYr48i@7h#oe=7%B>Uz5v#Yl3r4N$RfpZ(neNOBZBfSZV%!6Aja;C=jnCrP(h>eIl zA5xq(qHBANUu5J$GQEHQKElUpyj-vgmmLh9J;}mRF)@K~lOTRn)YQkt#2C!ChrI-J z2IOW01`gRwU%E8VT(l+Qn|i2dR^}XzlwmFfCB#*pMwp)v8)mO1vy_6#+e3@d6Bb_G zr^Ci>9534%=?92F}DxCIE@(gYwC>StjK+M-C=~yeLhUUcJ+6bSnsm1^OnrR zhZtT5d3vMh34kMuAqqTB7URyFXaeWv=Zl>-H3Rlt()#sMy-x3@xUJn~ds&3YQA#eE zxR)&CcpZkGzCE1w{mROUiuWbm+W=`RUpKOMqAojWS}0sF9sXDZV~TqYTysHIOzC|e zsKg@CJSzv*4~_v^?1Fa_Pjk)s&6|=%xkHFIyVH8eT3Xc{&?H5h5@P|g09D;8+Y
    r^1vuDw@5^QHow@g%bI@#W^8GPM>FD*o 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 literal 0 HcmV?d00001 From 6c76bc66054d7c2b73d43ed4aba89e284d489a91 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Tue, 28 Apr 2026 15:14:17 -0500 Subject: [PATCH 08/12] Cleanup Unity tutorial part 2 --- .../00300-unity-tutorial/00300-part-2.md | 69 ++++++++----------- .../00500-godot-tutorial/00300-part-2.md | 9 +-- .../00500-godot-tutorial/00500-part-4.md | 2 +- 3 files changed, 36 insertions(+), 44 deletions(-) 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..fa88d399b8f 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 `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/00500-godot-tutorial/00300-part-2.md b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00300-part-2.md index d317958065a..845219c5e26 100644 --- 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 @@ -20,8 +20,9 @@ Now that we have our client project setup we can configure the module directory. 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 -└── spacetimedb/ # This is where your server module lives +├── ... # 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. @@ -673,7 +674,7 @@ This will also generate a file `module_bindings/SpacetimeDBClient.g.cs` with a t At this point we can connect your Godot client to the server. Replace your imports at the top of the `GameManager.cs` file with: -```cs +```csharp using System; using System.Collections.Generic; using SpacetimeDB; @@ -683,7 +684,7 @@ using Godot; Replace the implementation of the `GameManager` class with the following. -```cs +```csharp public partial class GameManager : Node { public static event Action OnConnected; 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 e910bfe03fc..a9b717649c2 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 @@ -534,7 +534,7 @@ spacetime generate --lang csharp --out-dir ../../module_bindings 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: -```cs +```csharp public override void _Process(double delta) { if (!IsLocalPlayer || NumberOfOwnedCircles == 0 || !GameManager.IsConnected()) return; From 3ee95c682a18728d97400eeb58ae579656a1087d Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Tue, 28 Apr 2026 16:50:58 -0500 Subject: [PATCH 09/12] Fix and polish part 3 and part 4 of the Unity tutorial --- .../00300-unity-tutorial/00400-part-3.md | 467 +++++++++--------- .../00300-unity-tutorial/00500-part-4.md | 142 +++--- .../00500-godot-tutorial/00500-part-4.md | 2 - 3 files changed, 298 insertions(+), 313 deletions(-) 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..741b96b9986 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,7 +916,7 @@ 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. // --> This errors because they use the same AuthToken There's still plenty more we can do to build this into a proper game though. For example, you might want to also add 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 a9b717649c2..2ecdf0faaea 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 @@ -847,8 +847,6 @@ Notice that the food automatically respawns as you vaccuum them up. This is beca - `` 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` -- Your `GameManager.cs` should look something like this: To delete your Maincloud database, you can run: `spacetime delete --server maincloud ` From c86b5a8478326e3ac4f8919d9ed5873887638f0c Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 29 Apr 2026 09:43:13 -0500 Subject: [PATCH 10/12] Add Godot SDK --- .../00300-unity-tutorial/00300-part-2.md | 2 +- sdks/csharp/README.md | 14 ++- sdks/csharp/src/AuthToken.cs | 96 ++++++++++----- sdks/csharp/src/GodotDebugLogger.cs | 32 +++++ sdks/csharp/src/ISpacetimeDBLogger.cs | 2 + sdks/csharp/src/STDBUpdateManager.cs | 110 ++++++++++++++++++ 6 files changed, 223 insertions(+), 33 deletions(-) create mode 100644 sdks/csharp/src/GodotDebugLogger.cs create mode 100644 sdks/csharp/src/STDBUpdateManager.cs 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 fa88d399b8f..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 @@ -669,7 +669,7 @@ This will generate a set of files in the `module_bindings` directory (inside you └── 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 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 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. > 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 From 07c8d2689730b1339d73f746abbc677b72210bd8 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 30 Apr 2026 09:17:46 -0500 Subject: [PATCH 11/12] Add notes about auth tokens and splitting into multiple circles --- .../00300-tutorials/00300-unity-tutorial/00500-part-4.md | 3 ++- .../00300-tutorials/00500-godot-tutorial/00500-part-4.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 741b96b9986..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 @@ -916,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. // --> This errors because they use the same AuthToken +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/00500-part-4.md b/docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial/00500-part-4.md index 2ecdf0faaea..fd36dbb6930 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 @@ -882,13 +882,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 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 +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! From 071321c1754c55938b72474bbd5f02f63a2e49cb Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 30 Apr 2026 09:21:48 -0500 Subject: [PATCH 12/12] Update demo files --- demo/Blackholio/client-godot/AuthToken.cs | 41 --- .../client-godot/CameraController.cs | 86 +++--- .../client-godot/CameraController.cs.uid | 2 +- demo/Blackholio/client-godot/Circle2D.cs | 11 +- demo/Blackholio/client-godot/Circle2D.cs.uid | 2 +- .../client-godot/CircleController.cs | 198 ++++++------- .../client-godot/CircleController.cs.uid | 2 +- .../client-godot/EntityController.cs | 84 +++--- .../client-godot/EntityController.cs.uid | 2 +- demo/Blackholio/client-godot/Extensions.cs | 12 +- .../Blackholio/client-godot/Extensions.cs.uid | 2 +- .../Blackholio/client-godot/FoodController.cs | 4 +- .../client-godot/FoodController.cs.uid | 2 +- demo/Blackholio/client-godot/GameManager.cs | 279 +++++++++--------- .../client-godot/GameManager.cs.uid | 2 +- demo/Blackholio/client-godot/Instantiator.cs | 5 +- .../client-godot/Instantiator.cs.uid | 2 +- .../client-godot/PlayerController.cs | 169 ++++++----- .../client-godot/PlayerController.cs.uid | 2 +- .../client-godot/STDBUpdateManager.cs | 110 ------- .../client-godot/STDBUpdateManager.cs.uid | 1 - demo/Blackholio/client-godot/main.tscn | 10 +- .../Reducers/EnterGame.g.cs.uid | 2 +- .../Reducers/UpdatePlayerInput.g.cs.uid | 2 +- .../SpacetimeDBClient.g.cs.uid | 2 +- .../module_bindings/Tables/Circle.g.cs.uid | 2 +- .../module_bindings/Tables/Config.g.cs.uid | 2 +- .../module_bindings/Tables/Entity.g.cs.uid | 2 +- .../module_bindings/Tables/Food.g.cs.uid | 2 +- .../module_bindings/Tables/Player.g.cs.uid | 2 +- .../module_bindings/Types/Circle.g.cs.uid | 2 +- .../module_bindings/Types/Config.g.cs.uid | 2 +- .../module_bindings/Types/DbVector2.g.cs.uid | 2 +- .../module_bindings/Types/Entity.g.cs.uid | 2 +- .../module_bindings/Types/Food.g.cs.uid | 2 +- .../Types/MoveAllPlayersTimer.g.cs.uid | 2 +- .../module_bindings/Types/Player.g.cs.uid | 2 +- .../Types/SpawnFoodTimer.g.cs.uid | 2 +- 38 files changed, 453 insertions(+), 607 deletions(-) delete mode 100644 demo/Blackholio/client-godot/AuthToken.cs delete mode 100644 demo/Blackholio/client-godot/STDBUpdateManager.cs delete mode 100644 demo/Blackholio/client-godot/STDBUpdateManager.cs.uid diff --git a/demo/Blackholio/client-godot/AuthToken.cs b/demo/Blackholio/client-godot/AuthToken.cs deleted file mode 100644 index b351a005ffb..00000000000 --- a/demo/Blackholio/client-godot/AuthToken.cs +++ /dev/null @@ -1,41 +0,0 @@ -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 token) => TryGetToken(null, out token); - public static bool TryGetToken(string keySuffix, out string token) - { - token = GetToken(keySuffix); - return !string.IsNullOrWhiteSpace(token); - } - - 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}"; - } -} \ No newline at end of file diff --git a/demo/Blackholio/client-godot/CameraController.cs b/demo/Blackholio/client-godot/CameraController.cs index 62657d34198..cb11b025661 100644 --- a/demo/Blackholio/client-godot/CameraController.cs +++ b/demo/Blackholio/client-godot/CameraController.cs @@ -1,49 +1,49 @@ -using Godot; +using Godot; public partial class CameraController : Camera2D { - [Export] - public float BaseVisibleRadius { get; set; } = 50.0f; + [Export] + public float BaseVisibleRadius { get; set; } = 50.0f; - [Export] - public float FollowLerpSpeed { get; set; } = 8.0f; + [Export] + public float FollowLerpSpeed { get; set; } = 8.0f; - [Export] - public float ZoomLerpSpeed { get; set; } = 2.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; -} + 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 index 97f18fc189f..0c921f2962e 100644 --- a/demo/Blackholio/client-godot/CameraController.cs.uid +++ b/demo/Blackholio/client-godot/CameraController.cs.uid @@ -1 +1 @@ -uid://b0l7e61svh6b4 +uid://c5uuweuf2vu0e diff --git a/demo/Blackholio/client-godot/Circle2D.cs b/demo/Blackholio/client-godot/Circle2D.cs index 0c568bc0ae1..c414b7b6843 100644 --- a/demo/Blackholio/client-godot/Circle2D.cs +++ b/demo/Blackholio/client-godot/Circle2D.cs @@ -1,6 +1,5 @@ using Godot; -[Tool] public partial class Circle2D : Node2D { private float _radius = 10.0f; @@ -10,10 +9,7 @@ public float Radius get => _radius; set { - if (Mathf.IsEqualApprox(_radius, value)) - { - return; - } + if (Mathf.IsEqualApprox(_radius, value)) return; _radius = value; QueueRedraw(); @@ -27,10 +23,7 @@ public Color Color get => _color; set { - if (_color == value) - { - return; - } + if (_color == value) return; _color = value; QueueRedraw(); diff --git a/demo/Blackholio/client-godot/Circle2D.cs.uid b/demo/Blackholio/client-godot/Circle2D.cs.uid index 05ecbbb2c3a..5119a960d52 100644 --- a/demo/Blackholio/client-godot/Circle2D.cs.uid +++ b/demo/Blackholio/client-godot/Circle2D.cs.uid @@ -1 +1 @@ -uid://u1eb430g524x +uid://7lmrsq2i7mi1 diff --git a/demo/Blackholio/client-godot/CircleController.cs b/demo/Blackholio/client-godot/CircleController.cs index 7ce310f5985..73504709042 100644 --- a/demo/Blackholio/client-godot/CircleController.cs +++ b/demo/Blackholio/client-godot/CircleController.cs @@ -3,113 +3,113 @@ 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 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) - { + 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); + 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; - } - } + 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 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); - } + 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 _Process(double delta) + { + base._Process(delta); + UpdateScreenLabelPosition(); + } - public override void OnDelete() - { - base.OnDelete(); + public override void OnDelete() + { + base.OnDelete(); - if (IsInstanceValid(Label)) - { - Label.QueueFree(); - } + if (IsInstanceValid(Label)) + { + Label.QueueFree(); + } - OwnerPlayer?.OnCircleDeleted(this); - } + 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); - } + 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 index c01c4d7659b..86a1c5d4c36 100644 --- a/demo/Blackholio/client-godot/CircleController.cs.uid +++ b/demo/Blackholio/client-godot/CircleController.cs.uid @@ -1 +1 @@ -uid://cd0xlwbeuylpo +uid://c328rnk2rjynl diff --git a/demo/Blackholio/client-godot/EntityController.cs b/demo/Blackholio/client-godot/EntityController.cs index 12ee9057efe..207354f0489 100644 --- a/demo/Blackholio/client-godot/EntityController.cs +++ b/demo/Blackholio/client-godot/EntityController.cs @@ -3,46 +3,46 @@ 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; } = 1; - - 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 newVal) - { - LerpTime = 0.0f; - LerpStartPosition = GlobalPosition; - TargetPosition = (Vector2)newVal.Position; - TargetRadius = MassToRadius(newVal.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); + 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 index c7e86c3cf36..d50155b0921 100644 --- a/demo/Blackholio/client-godot/EntityController.cs.uid +++ b/demo/Blackholio/client-godot/EntityController.cs.uid @@ -1 +1 @@ -uid://cap5fd02qvfxf +uid://bhtg3omtsxp7d diff --git a/demo/Blackholio/client-godot/Extensions.cs b/demo/Blackholio/client-godot/Extensions.cs index 6060f78c13f..fe239418aba 100644 --- a/demo/Blackholio/client-godot/Extensions.cs +++ b/demo/Blackholio/client-godot/Extensions.cs @@ -2,9 +2,9 @@ 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); - } -} \ No newline at end of file + 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 index 802f367339b..6052bfbd55c 100644 --- a/demo/Blackholio/client-godot/Extensions.cs.uid +++ b/demo/Blackholio/client-godot/Extensions.cs.uid @@ -1 +1 @@ -uid://1i71lnxdcjog +uid://cxx8tt4m0yxbr diff --git a/demo/Blackholio/client-godot/FoodController.cs b/demo/Blackholio/client-godot/FoodController.cs index 2787e19dffc..fc46ba0dbca 100644 --- a/demo/Blackholio/client-godot/FoodController.cs +++ b/demo/Blackholio/client-godot/FoodController.cs @@ -1,4 +1,4 @@ -using Godot; +using Godot; using SpacetimeDB.Types; public partial class FoodController : EntityController @@ -14,4 +14,4 @@ public partial class FoodController : EntityController ]; 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 index ed707332126..48205309e59 100644 --- a/demo/Blackholio/client-godot/FoodController.cs.uid +++ b/demo/Blackholio/client-godot/FoodController.cs.uid @@ -1 +1 @@ -uid://dol6i5dumj0xt +uid://cr4egm415cpw4 diff --git a/demo/Blackholio/client-godot/GameManager.cs b/demo/Blackholio/client-godot/GameManager.cs index aa16a8bc967..c39c37507bf 100644 --- a/demo/Blackholio/client-godot/GameManager.cs +++ b/demo/Blackholio/client-godot/GameManager.cs @@ -1,145 +1,148 @@ using System; -using Godot; +using System.Collections.Generic; using SpacetimeDB; using SpacetimeDB.Types; +using Godot; -public partial class GameManager : Node2D +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; - } - - private void HandleConnect(DbConnection conn, Identity identity, string token) - { - GD.Print("Connected."); - AuthToken.SaveToken(token); - LocalIdentity = identity; - - OnConnected?.Invoke(); - - AddChild(new Instantiator(conn)); - - 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); + 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)); - } + 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 index a2385ceb495..c8124bc6265 100644 --- a/demo/Blackholio/client-godot/GameManager.cs.uid +++ b/demo/Blackholio/client-godot/GameManager.cs.uid @@ -1 +1 @@ -uid://p2e72osy6dx5 +uid://ce8dl6ahr07ou diff --git a/demo/Blackholio/client-godot/Instantiator.cs b/demo/Blackholio/client-godot/Instantiator.cs index 0fb88a01996..6d75a416466 100644 --- a/demo/Blackholio/client-godot/Instantiator.cs +++ b/demo/Blackholio/client-godot/Instantiator.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Godot; using SpacetimeDB.Types; @@ -46,6 +46,7 @@ public Instantiator(DbConnection conn) public override void _ExitTree() { + GD.PrintErr("Instantiator Exit Tree"); Conn = null; } @@ -138,4 +139,4 @@ private PlayerController SpawnPlayer(Player player) 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 index 1cd24bc9033..7609abb6396 100644 --- a/demo/Blackholio/client-godot/Instantiator.cs.uid +++ b/demo/Blackholio/client-godot/Instantiator.cs.uid @@ -1 +1 @@ -uid://bm2c8ov8aaoi8 +uid://g80425tangpw diff --git a/demo/Blackholio/client-godot/PlayerController.cs b/demo/Blackholio/client-godot/PlayerController.cs index 9e1b6f04d9e..6dd2b4d8566 100644 --- a/demo/Blackholio/client-godot/PlayerController.cs +++ b/demo/Blackholio/client-godot/PlayerController.cs @@ -1,66 +1,65 @@ -using System.Collections.Generic; +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(); - - // Automated testing members. - 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); + 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) { @@ -93,32 +92,32 @@ public bool TryGetCenterOfMass(out Vector2 centerOfMass) 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); + 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 index 69530043c59..993ef27fdf5 100644 --- a/demo/Blackholio/client-godot/PlayerController.cs.uid +++ b/demo/Blackholio/client-godot/PlayerController.cs.uid @@ -1 +1 @@ -uid://d3l75ikpjh23o +uid://x3x73eqe4x28 diff --git a/demo/Blackholio/client-godot/STDBUpdateManager.cs b/demo/Blackholio/client-godot/STDBUpdateManager.cs deleted file mode 100644 index c2662b5b310..00000000000 --- a/demo/Blackholio/client-godot/STDBUpdateManager.cs +++ /dev/null @@ -1,110 +0,0 @@ -#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 diff --git a/demo/Blackholio/client-godot/STDBUpdateManager.cs.uid b/demo/Blackholio/client-godot/STDBUpdateManager.cs.uid deleted file mode 100644 index b34e8113fbd..00000000000 --- a/demo/Blackholio/client-godot/STDBUpdateManager.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dx02o6rbuv8fq diff --git a/demo/Blackholio/client-godot/main.tscn b/demo/Blackholio/client-godot/main.tscn index 95c012a83f5..0d852591ef6 100644 --- a/demo/Blackholio/client-godot/main.tscn +++ b/demo/Blackholio/client-godot/main.tscn @@ -1,6 +1,8 @@ -[gd_scene format=3 uid="uid://cjb7808stemnn"] +[gd_scene format=3 uid="uid://bu0hxewrjspqk"] -[ext_resource type="Script" uid="uid://p2e72osy6dx5" path="res://GameManager.cs" id="1_7mycd"] +[ext_resource type="Script" uid="uid://ce8dl6ahr07ou" path="res://GameManager.cs" id="1_ig7tw"] -[node name="Main" type="Node2D" unique_id=211175705] -script = ExtResource("1_7mycd") +[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.uid b/demo/Blackholio/client-godot/module_bindings/Reducers/EnterGame.g.cs.uid index df2fe0a92ee..91c04f6b64a 100644 --- a/demo/Blackholio/client-godot/module_bindings/Reducers/EnterGame.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/Reducers/EnterGame.g.cs.uid @@ -1 +1 @@ -uid://dnktvv6ucqo8o +uid://dwyi6cflmpxxg 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 index 55840e521ea..b4df7bd98c0 100644 --- a/demo/Blackholio/client-godot/module_bindings/Reducers/UpdatePlayerInput.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/Reducers/UpdatePlayerInput.g.cs.uid @@ -1 +1 @@ -uid://ba2y10myukhjo +uid://bwvobqbvsrp4w diff --git a/demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs.uid b/demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs.uid index d45139d4160..00b49ce587b 100644 --- a/demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/SpacetimeDBClient.g.cs.uid @@ -1 +1 @@ -uid://ctc160vqwf6yf +uid://c0gmhdkyjh2xs 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 index a857db9fd2b..5e2b940978e 100644 --- a/demo/Blackholio/client-godot/module_bindings/Tables/Circle.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Circle.g.cs.uid @@ -1 +1 @@ -uid://bcddkkce7vglq +uid://dtumpr4bxjf6b 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 index 161eeb30806..fe968262c74 100644 --- a/demo/Blackholio/client-godot/module_bindings/Tables/Config.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Config.g.cs.uid @@ -1 +1 @@ -uid://bwsauj58rorqh +uid://cfol51xw3dhgy 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 index b168ce4928f..19e779b24fa 100644 --- a/demo/Blackholio/client-godot/module_bindings/Tables/Entity.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Entity.g.cs.uid @@ -1 +1 @@ -uid://dkcmtlay2kya1 +uid://c17wdsxgtjdb2 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 index c168e6d4866..9767ab3ae22 100644 --- a/demo/Blackholio/client-godot/module_bindings/Tables/Food.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Food.g.cs.uid @@ -1 +1 @@ -uid://dbh8bqbmcfmwx +uid://bxxge045y3x8s 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 index e12e4c0a99a..a84eee2afb5 100644 --- a/demo/Blackholio/client-godot/module_bindings/Tables/Player.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/Tables/Player.g.cs.uid @@ -1 +1 @@ -uid://cuwt8n7drvq6y +uid://byugnhsnblyxe 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 index 4fd4f147dc7..685d0d33244 100644 --- a/demo/Blackholio/client-godot/module_bindings/Types/Circle.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/Types/Circle.g.cs.uid @@ -1 +1 @@ -uid://5g25xoh6rbcd +uid://c5uxjqm2dett7 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 index e8b6b2d279f..54cc203106b 100644 --- a/demo/Blackholio/client-godot/module_bindings/Types/Config.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/Types/Config.g.cs.uid @@ -1 +1 @@ -uid://b4f33auffxcff +uid://bamm7tde4fc6q 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 index 7be1e55b405..d87b50e465b 100644 --- a/demo/Blackholio/client-godot/module_bindings/Types/DbVector2.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/Types/DbVector2.g.cs.uid @@ -1 +1 @@ -uid://b0d2uwpmc35ve +uid://b72fw0ktxnllr 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 index b66f30bf44f..9290b54152d 100644 --- a/demo/Blackholio/client-godot/module_bindings/Types/Entity.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/Types/Entity.g.cs.uid @@ -1 +1 @@ -uid://df5fpdyx2loie +uid://bbqoaj62svbsb 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 index 0af6321c5fd..3508b76ab17 100644 --- a/demo/Blackholio/client-godot/module_bindings/Types/Food.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/Types/Food.g.cs.uid @@ -1 +1 @@ -uid://bc26loskn8hm6 +uid://bc53r6j5ea1x5 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 index 5f4ac353f23..2d1d4b9123f 100644 --- a/demo/Blackholio/client-godot/module_bindings/Types/MoveAllPlayersTimer.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/Types/MoveAllPlayersTimer.g.cs.uid @@ -1 +1 @@ -uid://wemroaeunvsa +uid://bbopgetgl0ia4 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 index 745db96f2a6..df230d821ab 100644 --- a/demo/Blackholio/client-godot/module_bindings/Types/Player.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/Types/Player.g.cs.uid @@ -1 +1 @@ -uid://bqvnk2o68p3ve +uid://dxabwjoo16o1g 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 index 257bae214d2..23b1a88c154 100644 --- a/demo/Blackholio/client-godot/module_bindings/Types/SpawnFoodTimer.g.cs.uid +++ b/demo/Blackholio/client-godot/module_bindings/Types/SpawnFoodTimer.g.cs.uid @@ -1 +1 @@ -uid://bub0q45hec0b8 +uid://cr6a0dknfjvxt