ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SpacetimeDB × Godot 实战:创建服务端模块并打通客户端连接(Blackhol.io 教程第 2 部分)

SpacetimeDB × Godot 实战:创建服务端模块并打通客户端连接(Blackhol.io 教程第 2 部分) SpacetimeDB × Godot 实战创建服务端模块并打通客户端连接Blackhol.io 教程第 2 部分【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB导读本教程来自 SpacetimeDB 官方 Godot 系列教程的第 2 部分承接 第 1 部分Godot 客户端项目搭建。你将学会在 Godot 客户端项目内创建 SpacetimeDB 服务端模块module用 Rust、C# 或 C 定义表结构与 reducer并最终把 Godot 客户端真正接上本地数据库——完成从表定义 → 编写 reducer → 发布模块 → 生成客户端绑定 → 订阅同步的完整闭环。读完本部分你将具备为任何 Godot 多人游戏搭建 SpacetimeDB 服务端骨架并跑通首条连接链路的能力。教程围绕一个名为Blackhol.io的太空主题多人吞噬游戏灵感来自 agar.io展开其完整成品源码可在仓库 demo/Blackholio/server-rust/src/lib.rs 中查看本部分的代码正是它的起点。项目结构服务端模块与客户端的分区约定在开始写代码前先理解 SpacetimeDB 项目的目录约定。无论你选择哪种服务端语言服务端模块永远放在客户端目录下的spacetimedb子目录里整体结构如下blackholio/ # Godot 项目所在目录 ├── blackholio.csproj ├── module_bindings/ # 客户端逻辑与模块通信的绑定代码 ├── ... # 其余 Godot 文件 └── blackholio-server/ └── spacetimedb/ # 服务端模块所在位置其中module_bindings/存放由 CLI 生成的、可在 Godot 客户端中使用的表/类型/reducer 绑定代码它只要位于 Godot 项目的res://内即可位置本身可以自定义后续步骤会配置blackholio-server/spacetimedb/则是服务端模块源码所在模块与客户端各自独立编译、互不干扰。在模块正式初始化之前请确认你已经安装了spacetimeCLI安装方式见 Getting Started 指南。创建服务端模块spacetime init在与 Godot 项目blackholio同级的目录中注意是你在第 1 部分创建的那个blackholio目录而不是它的父级或子级用spacetime init初始化服务端模块项目。该命令会要求你提供项目路径与数据库名这里统一使用blackholio-server作为项目路径、blackholio作为数据库名spacetime init --lang rust --server-only blackholio--lang参数决定模块语言教程支持三种语言命令模块源码文件C#spacetime init --lang csharp --server-only blackholioblackholio-server/spacetimedb/Lib.csRustspacetime init --lang rust --server-only blackholioblackholio-server/spacetimedb/src/lib.rsCspacetime init --lang cpp --server-only blackholioblackholio-server/spacetimedb/src/lib.cpp--server-only表示仅生成服务端代码不生成客户端示例。命令执行后会在blackholio目录内创建blackholio-server/文件夹其中spacetimedb/子目录即为服务端模块工程。定义 SpacetimeDB 表从 Config 单例表开始清空模板文件并添加导入打开对应的模块源文件Lib.cs/lib.rs/lib.cpp删除原有内容从零编写。首先添加导入语句// Rust (lib.rs) use std::time::Duration; use spacetimedb::{rand::Rng, Identity, SpacetimeType, ReducerContext, ScheduleAt, Table, Timestamp};// C# (Lib.cs) using SpacetimeDB; public static partial class Module { }// C (lib.cpp) #include spacetimedb.h using namespace SpacetimeDB;其中部分导入如rand::Rng、ScheduleAt、Timestamp在后续教程部分才会用到暂时保留即可。表Table是什么SpacetimeDB 的表table是关系型数据库表存储行row数据概念与 SQL 表类似。但它与普通关系型数据库表有两点关键差异数据完全驻留内存访问速度极快SpacetimeDB 同时会自动将数据持久化到磁盘表定义在模块代码中而不是写在 SQL 建表语句里。关于表设计哲学与完整能力索引、约束、列类型、可见性等可进一步阅读 Tables 文档。定义 Config 表游戏需要一个存储世界元数据的表。Config被设计为单例表——表中只有一行且id恒为 0// 该表作为单例使用因此表中只有一个 id 为 0 的元素 #[spacetimedb::table(accessor config, public)] pub struct Config { #[primary_key] pub id: i32, pub world_size: i64, }[Table(Accessor config, Public true)] public partial struct Config { [PrimaryKey] public int id; public long world_size; }struct Config { int32_t id; int64_t world_size; }; SPACETIMEDB_STRUCT(Config, id, world_size); SPACETIMEDB_TABLE(Config, config, Public); FIELD_PrimaryKey(config, id);逐段拆解这段定义Config是一个普通的结构体struct两个字段id、world_sizeRust 的#[spacetimedb::table]过程宏 / C# 的[Table]特性 / C 的SPACETIMEDB_TABLE宏向 SpacetimeDB 声明为这个结构体的字段创建一个新表accessorRust/C 写法或AccessorC# 写法是表的访问器名称也就是你在 SQL 查询和订阅中使用表名public/Public true是可见性修饰符表示该表的行对所有人可见。若省略public表默认为私有只有 reducer 与数据库所有者可见#[primary_key]/[PrimaryKey]/FIELD_PrimaryKey将id字段指定为表的主键。三个字段名统一采用lower_snake_case目的是让列名在跨语言时保持一致如果你偏好camelCase或PascalCase也可以使用SpacetimeDB 允许只需注意访问器的大小写敏感特性详见 Tables 文档中的命名约定。关于主键的重要语义主键定义了行的身份。修改一行但不改动主键被视为一次更新update而一旦你更改了主键值则等价于删除旧行并插入新行。创建实体类型SpacetimeType与实体表自定义类型 DbVector2游戏需要在表中存储 2D 坐标。这里定义一个SpacetimeType而非表类型可以作为一种列类型被多个表引用但它本身不存储数据、不构成一张表// 这让我们可以在表中存储 2D 点 #[derive(SpacetimeType, Clone, Debug)] pub struct DbVector2 { pub x: f32, pub y: f32, }[Type] public partial struct DbVector2 { public float x; public float y; public DbVector2(float x, float y) { this.x x; this.y y; } }struct DbVector2 { float x; float y; }; SPACETIMEDB_STRUCT(DbVector2, x, y);实体表Entity / Circle / Food现在定义代表游戏世界中对象的实体表。设计决策是所有实体共享position位置与mass质量两个字段存放在entity表中而不同类型实体Food、Circle通过各自的表持有entity_id引用entity表中的行从而在公共字段之外附加专属数据#[spacetimedb::table(accessor entity, public)] #[derive(Debug, Clone)] pub struct Entity { // auto_inc 属性表示该值由 SpacetimeDB 在插入时自动确定 #[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, }[Table(Accessor entity, Public true)] public partial struct Entity { [PrimaryKey, AutoInc] public int entity_id; public DbVector2 position; public int mass; } [Table(Accessor circle, Public true)] public partial struct Circle { [PrimaryKey] public int entity_id; [SpacetimeDB.Index.BTree] public int player_id; public DbVector2 direction; public float speed; public Timestamp last_split_time; } [Table(Accessor food, Public true)] public partial struct Food { [PrimaryKey] public int entity_id; }struct Entity { // FIELD_PrimaryKeyAutoInc 约束表示该值由 SpacetimeDB 在插入时自动确定 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);三张表的含义entity游戏世界中所有对象的公共信息。entity_id加了auto_inc/[AutoInc]/FIELD_PrimaryKeyAutoInc插入时由 SpacetimeDB 自动分配自增 IDfood食物没有额外字段所以这张表仅仅表示哪些entity_id是食物circle玩家控制的圆球实体。额外字段player_id标明归属玩家并加了 BTree 索引#[index(btree)]/[SpacetimeDB.Index.BTree]/FIELD_Index便于按玩家快速检索、direction移动方向、speed速度、last_split_time上次分裂时间后续分裂/合并玩法会用到。这种公共字段 子表引用的分解式设计正是 Tables 文档中推荐的按访问模式组织数据 的实践高频更新的位置数据与低频更新的附加数据分离可以减少订阅带宽并提升缓存效率。表示玩家Player 表与唯一/自增约束接着定义存储玩家数据的Player表。这里引入了两个新概念#[spacetimedb::table(accessor player, public)] #[derive(Debug, Clone)] pub struct Player { #[primary_key] identity: Identity, #[unique] #[auto_inc] player_id: i32, name: String, }[Table(Accessor player, Public true)] public partial struct Player { [PrimaryKey] public Identity identity; [Unique, AutoInc] public int player_id; public string name; }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);#[unique]/[Unique]/FIELD_UniqueAutoInc唯一约束保证player表中任意两行的player_id不会重复#[auto_inc]/[AutoInc]自增约束插入时自动分配递增整数值。C 的FIELD_UniqueAutoInc宏把唯一 自增两个约束合并在一次声明里Identity字段Identity是 SpacetimeDB 用于唯一标识并认证用户的类型。它天然适合作为player表的主键——每个玩家身份对应一行玩家数据。对比仓库中完整的 Blackhol.io 实现可见正式版还在此基础上扩展了logged_out_player等离线表同一结构体上叠加多个#[spacetimedb::table]特性即可让同一行类型拥有多张独立表相关示例见 demo/Blackholio/server-rust/src/lib.rs。编写第一个 ReducerReducer 概念Reducer是模块中可被客户端远程调用的函数是 SpacetimeDB 中唯一能修改表数据的途径见 Functions 文档 中的能力对照表。这个术语由 Clockwork Labs 提出一个 reducer 执行时会把一批插入与删除归约reduce进数据库状态其思想源自函数式编程与 React Redux 等框架中的 reducer 概念一脉相承。Reducer 有两个至关重要的执行语义事务性与原子性所有 reducer 都在数据库事务内执行。从 reducer 内部看所有变更立即可见但从外部看只有 reducer完整成功后变更才生效失败即回滚若 reducer 返回错误或发生 panic数据库会整体回滚到调用前的状态就像函数从未被调用过。对复杂应用而言这一保证的价值会在后续开发中不断体现。Reducer 是纯数据库操作无法访问网络与文件系统需要外部 I/O 时应改用 Procedure详见 Functions 文档。编写 debug reducer先写一个最简单的调试用 reducer它不修改任何表只打印调用者的Identity。#[spacetimedb::reducer] pub fn debug(ctx: ReducerContext) - Result(), String { log::debug!(This reducer was called by {}., ctx.sender()); Ok(()) }[Reducer] public static void Debug(ReducerContext ctx) { Log.Info($This reducer was called by {ctx.Sender}); }SPACETIMEDB_REDUCER(debug, ReducerContext ctx) { LOG_INFO(This reducer was called by ctx.sender().to_string()); return Ok(); }注意 Rust 版 reducer 的签名规范第一参数必须为ReducerContext返回类型可以是()、Result(), String或Result(), EE: Display如需调用insert、iter、count等表操作方法还必须use spacetimedb::Table;详见 Reducers 文档 中的注意事项。发布模块并调用 reducer启动本地 SpacetimeDB在新终端窗口中启动本地实例spacetime start看到如下输出即表示本地服务已就绪Starting SpacetimeDB listening on 127.0.0.1:3000登录并发布另开一个终端进入blackholio-server/spacetimedb目录。若尚未登录 CLI先执行spacetime login登录 SpacetimeDB 网站账号然后发布模块spacetime publish --server local blackholio发布成功的日志大致如下Build finished successfully. Uploading to local http://127.0.0.1:3000 Publishing module... Created new database with name: blackholio, identity: c200d2c69b4524292b91822afac8ab016c15968ac993c28711f68c6bc40b89d5两点登录相关的说明通过 GitHub 登录spacetime login时获得的 token 由auth.spacetimedb.com签发可保证身份可恢复若改用spacetime login --server-issued-login local则获得由本地服务器直接签发的身份但该 token丢失后不可恢复且仅被签发它的服务器识别。调用 reducer 并查看日志spacetime call --server local blackholio debug调用成功时命令本身无输出但可以通过日志查看 reducer 的执行结果spacetime logs --server local blackholio日志中可以看到表创建过程与 reducer 被调用时打印的身份信息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.日志同时印证了前面定义的五张表circle、config、entity、food、player全部创建成功。连接客户端生命周期 reducer 与订阅同步将 debug 改为 connect 生命周期 reducerSpacetimeDB 允许定义由系统事件自动触发的特殊 reducer。把debug重命名为connect并加上生命周期标记#[spacetimedb::reducer(client_connected)] pub fn connect(ctx: ReducerContext) - Result(), String { log::debug!({} just connected., ctx.sender()); Ok(()) }[Reducer(ReducerKind.ClientConnected)] public static void Connect(ReducerContext ctx) { Log.Info(${ctx.Sender} just connected.); }SPACETIMEDB_CLIENT_CONNECTED(connect, ReducerContext ctx) { LOG_INFO(ctx.sender().to_string() just connected.); return Ok(); }client_connectedC# 写作ReducerKind.ClientConnectedC 写作SPACETIMEDB_CLIENT_CONNECTED告诉 SpacetimeDB每当有客户端连接数据库时由系统调用该 reducer。调用者的身份可从ctx.sender()/ctx.Sender获得。三种语言对应的生命周期 reducer 一览触发时机RustC#C模块首次发布或执行spacetime publish --server local name --delete-data清空数据库时initReducerKind.InitSPACETIMEDB_INIT用户连接到数据库时client_connectedReducerKind.ClientConnectedSPACETIMEDB_CLIENT_CONNECTED用户断开连接时client_disconnectedReducerKind.ClientDisconnectedSPACETIMEDB_CLIENT_DISCONNECTED修改后重新发布模块首次连接前connect生命周期 reducer 会在客户端连接时自动触发spacetime publish --server local blackholio在仓库的完整实现中connectreducer 被扩展为恢复离线玩家数据或为新玩家创建初始行的逻辑见 demo/Blackholio/server-rust/src/lib.rs 的 connect reducer这正是生命周期 reducer 支撑真实登录流程的典型用法。生成客户端绑定spacetime generatespacetimeCLI 内置了代码生成能力根据模块中的表、类型与 reducer自动生成可在 Godot 客户端使用的 C# 类型。在blackholio-server/spacetimedb目录下执行spacetime generate --lang csharp --out-dir ../../module_bindings该命令会在 Godot 项目blackholio目录下的module_bindings/中生成一组文件├── 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其中SpacetimeDBClient.g.cs包含一个类型感知type-aware的DbConnection类它将作为 Godot 客户端连接数据库的入口。各生成文件与模块中的定义一一对应Tables/下的表行类型、Types/下的列类型与自定义类型、Reducers/下的可调用 reducer 封装。GameManager配置并建立连接回到 Godot 侧。替换GameManager.cs顶部的导入using System; using System.Collections.Generic; using SpacetimeDB; using SpacetimeDB.Types; using Godot;将GameManager类整体替换为如下实现public partial class GameManager : Node { public static event Action OnConnected; public static event Action OnSubscriptionApplied; [Export] private string ServerUrl { get; set; } http://127.0.0.1:3000; [Export] private string DatabaseName { get; set; } blackholio; [Export] private Color BackgroundColor { get; set; } Colors.MidnightBlue; [Export] private float BorderThickness { get; set; } 5.0f; [Export] private Color BorderColor { get; set; } Colors.Goldenrod; [Export] private string DefaultPlayerName { get; set; } 3Blave; private static GameManager Instance { get; set; } public static Identity LocalIdentity { get; private set; } public static DbConnection Conn { get; private set; } public GameManager() { var builder DbConnection.Builder() .OnConnect(HandleConnect) .OnConnectError(HandleConnectError) .OnDisconnect(HandleDisconnect) .WithUri(ServerUrl) .WithDatabaseName(DatabaseName); if (AuthToken.TryGetToken(out var authToken)) { builder builder.WithToken(authToken); } Conn builder.Build(); STDBUpdateManager.Add(Conn); } public override void _EnterTree() { Instance this; } public override void _ExitTree() { Disconnect(); if (Instance this) { Instance null; } } public static bool IsConnected() Conn ! null Conn.IsActive; private void Disconnect() { STDBUpdateManager.Remove(Conn, true); Conn null; } // Called when we connect to SpacetimeDB and receive our client identity private void HandleConnect(DbConnection conn, Identity identity, string token) { GD.Print(Connected.); AuthToken.SaveToken(token); LocalIdentity identity; OnConnected?.Invoke(); // Request all tables Conn.SubscriptionBuilder() .OnApplied(HandleSubscriptionApplied) .SubscribeToAllTables(); } private void HandleConnectError(Exception ex) { GD.PrintErr($Connection error: {ex}); } private void HandleDisconnect(DbConnection _conn, Exception ex) { GD.Print(Disconnected.); if (ex ! null) { GD.PrintErr(ex); } } private void HandleSubscriptionApplied(SubscriptionEventContext ctx) { GD.Print(Subscription applied!); OnSubscriptionApplied?.Invoke(); } }这段代码的要点连接配置DbConnection.Builder()通过.WithUri(ServerUrl)与.WithDatabaseName(DatabaseName)指定目标服务器与数据库并通过.OnConnect/.OnConnectError/.OnDisconnect注册回调。ServerUrl、DatabaseName等字段带有[Export]可在 Godot 编辑器的 Inspector 中直接调整身份恢复连接成功后回调会收到token通过AuthToken.SaveToken(token)保存下次启动时AuthToken.TryGetToken会读取并随连接带上 token实现身份复用STDBUpdateManagerSTDBUpdateManager.Add(Conn)将连接挂入 Godot 的更新循环驱动客户端与 SpacetimeDB 之间消息的收发处理订阅HandleConnect中构建订阅并调用SubscribeToAllTables()让 SpacetimeDB 将全部表的状态同步到 Godot 客户端的本地缓存。关于 SDK 客户端缓存客户端缓存是由订阅查询定义的数据库的客户端视图。SpacetimeDB 保证订阅查询的结果在变化时被自动更新并推送到客户端缓存使客户端可以高效本地访问数据而无需反复向服务器发查询。更精细的订阅按查询、按表与行级onInsert/onUpdate/onDelete事件详见 Subscriptions 文档。排除嵌套项目编辑 blackholio.csproj由于 C# 服务端项目嵌套在 Godot 项目目录内必须强制将blackholio-server从 Godot 的 C#.csproj中排除否则会编译冲突。用任意文本编辑器打开Blackholio.csproj在PropertyGroup末尾添加一行DefaultItemExcludes$(DefaultItemExcludes);blackholio-server/**/DefaultItemExcludes修改后的完整文件应类似Project SdkGodot.NET.Sdk/4.6.2 PropertyGroup TargetFrameworknet8.0/TargetFramework TargetFramework Condition $(GodotTargetPlatform) android net9.0/TargetFramework EnableDynamicLoadingtrue/EnableDynamicLoading DefaultItemExcludes$(DefaultItemExcludes);blackholio-server/**/DefaultItemExcludes /PropertyGroup ItemGroup PackageReference IncludeSpacetimeDB.ClientSDK Version2.1.0 / /ItemGroup /Project运行验证在 Godot 中点击播放。如果一切顺利Godot 日志应出现SpacetimeDBClient: Connecting to ws://127.0.0.1:3000 blackholio Connected. Subscription applied!Subscription applied!表示 SDK 已评估订阅查询并将本地缓存与数据库表同步完成。同时服务端日志也能看到连接事件spacetime logs --server local blackholio ... 2025-01-10T03:51:02.078700Z DEBUG: src/lib.rs:63: c200fb5be9524bfb8289c351516a1d9ea800f70a17a9a6937f11c0ed3854087d just connected.这行日志正是connect生命周期 reducer 被触发时打印的客户端 → SpacetimeDB 的连接链路已完全打通。小结与下一步本部分完成了 Blackhol.io 的全部接线工作建立客户端/服务端目录约定用spacetime init创建模块工程用#[spacetimedb::table]/[Table]定义config、entity、circle、food、player表覆盖主键、自增、唯一、BTree 索引等约束并借助SpacetimeTypeDbVector2实现自定义列类型编写 reducer理解其事务性、原子性与失败回滚语义用spacetime start/publish/call/logs完成本地发布与调试闭环将debug改造为client_connected生命周期 reducer用spacetime generate生成客户端绑定编写GameManager建立连接并订阅全表最后通过排除嵌套项目完成编译集成。至此客户端已能连接数据库并收到实时同步的数据——游戏开发的基础设施已经就绪。接下来进入 第 3 部分游戏功能开发你将学习如何在 Godot 中访问表数据、调用 reducer让 Blackhol.io 真正动起来。【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表