SpacetimeDB Unreal 教程 Part 2:从零搭建服务端模块并打通 Unreal 客户端连接
【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB
本篇指南延续 Blackholio 教程的 Part 1:Unreal 项目环境搭建,带你完成 SpacetimeDB 服务端模块的创建、表结构设计与 reducer 编写,并通过spacetimeCLI 将模块发布到本地 SpacetimeDB,最后在 Unreal 客户端中建立 WebSocket 连接并订阅数据库表。读完本文,你将掌握spacetime init/publish/generate的完整命令链路、三语言(Rust/C#/C++)下的表与 reducer 定义方式,以及 C++/Blueprint 两种 Unreal 客户端接入方案。
项目结构:模块存放位置
无论服务端使用哪种语言编写,模块代码都统一放在客户端工程目录下的spacetimedb文件夹中。以 Blackholio 为例,最终的 Unreal 工程结构如下:
blackholio/ # Unreal project root ├── Binaries/ ├── blackholio.sln ├── blackholio.uproject ├── Config/ ├── Content/ ├── Plugins/ │ └── SpacetimeDbSdk/ # This is where the SpacetimeDB Unreal SDK lives ├── ... rest of Unreal files └── spacetimedb/ # This is where the server module lives其中Plugins/SpacetimeDbSdk即 SpacetimeDB 官方 Unreal 客户端 SDK 插件,在仓库中的对应源码位于 sdks/unreal/src/SpacetimeDbSdk,内含连接管理、BSATN 序列化、订阅缓存等完整实现;spacetimedb目录则是服务端模块,二者通过spacetimeCLI 生成的绑定代码桥接。
创建服务端模块
前置条件
- 已安装
spacetimeCLI,且版本≥ 1.4.0(该版本起才支持 Unreal Engine 代码生成)。可用spacetime --version查看版本,用spacetime version upgrade升级到最新版; - 若尚未安装 CLI,请参照 Getting Started 指南 完成安装;
- 确保已拥有 Part 1 中创建的
blackholioUnreal 工程目录。
初始化命令
在包含blackholio目录的同一级目录下运行(注意:这里的blackholio就是 Part 1 创建的那个工程目录),根据服务端语言选择对应命令:
# Rust 服务端 spacetime init --lang rust --server-only --project-path . blackholio # C# 服务端 spacetime init --lang csharp --server-only --project-path . blackholio # C++ 服务端(注意:C++ 模块目前仍是预览版) spacetime init --lang cpp --server-only --project-path . blackholio该命令会在 Unreal 工程blackholio目录内创建名为spacetimedb的文件夹并初始化对应语言的 SpacetimeDB 服务端工程。作为对照,仓库中 Blackholio 演示项目的 Rust 服务端完整实现位于 demo/Blackholio/server-rust/src/lib.rs,后续所有表与 reducer 都有对应的完整版本可参考。
定义 SpacetimeDB 表
本节需要编辑的模块文件根据语言不同:
| 语言 | 编辑文件 |
|---|---|
| Rust | blackholio/spacetimedb/src/lib.rs |
| C# | blackholio/spacetimedb/Lib.cs |
| C++ | blackholio/spacetimedb/src/lib.cpp |
重要提示:清空该文件原有内容,从零开始编写。
首先添加导入语句(部分暂未使用):
// Rust use std::time::Duration; use spacetimedb::{rand::Rng, Identity, SpacetimeType, ReducerContext, ScheduleAt, Table, Timestamp};// C# using SpacetimeDB; public static partial class Module { }// C++ #include "spacetimedb.h" using namespace SpacetimeDB;第一个表:Config
SpacetimeDB 中的表(table)与 SQL 关系型数据库的表概念类似,用于存储行数据;不同之处在于它是完全驻留内存的、访问速度极快,并且直接在模块代码中定义而非通过 SQL 语句创建。
先定义Config表,它作为单例表保存游戏世界的基本元数据(这里约定表中仅有一条id为 0 的记录):
// Rust // We're using this table as a singleton, so in this table // there will 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, }// C# // 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; }// C++ // 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);逐行拆解这段定义:
- Rust 中
spacetimedb::table过程宏、C# 中[Table]特性、C++ 中SPACETIMEDB_TABLE宏,都向 SpacetimeDB 声明"以此类型字段为行结构创建一个新表"; accessor(Rust/C++ 为表名参数)是表的访问名,也是后续用 SQL 查询该表时使用的名字;public可见性修饰符保证所有客户端都能看到该表行数据(SpacetimeDB 的表默认是私有的,私有表仅数据库所有者可访问);#[primary_key]/[PrimaryKey]/FIELD_PrimaryKey指定id为主键;- 列名在本教程中统一使用
lower_snake_case以保证跨语言一致;如果你更喜欢camelCase或PascalCase也可以使用。
关于主键需要特别注意:主键定义了行的"身份"。修改一行但不动主键,属于更新;一旦改动主键,则相当于删除旧行并插入新行。
关于表的更多定义方式(索引、约束、列类型),可查阅 Tables 文档。
SpacetimeType 与实体表
接下来定义一个SpacetimeType类型的DbVector2,用于在表中存储 2D 坐标。SpacetimeType与表的关键区别在于:表真正存储数据,而SpacetimeType只是允许你把这种类型作为列类型用于表中,本身不构成表。
// Rust // This allows us to store 2D points in tables. #[derive(SpacetimeType, Clone, Debug)] pub struct DbVector2 { pub x: f32, pub y: f32, }// C# // 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; } }// C++ // This allows us to store 2D points in tables. struct DbVector2 { float x; float y; }; SPACETIMEDB_STRUCT(DbVector2, x, y);随后定义三张实体表entity、circle、food:
// 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, }// C# [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; }// C++ 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);设计思路:
entity表代表游戏世界中的一个对象,统一携带position与mass两个公共字段;- 需要额外数据的实体类型,通过"新表 + 引用
entity表中entity_id的附加字段"实现组合。Food没有额外字段,所以food表只是一个entity_id集合,用于标记哪些实体是食物; Circle表代表由玩家控制的实体,额外字段包括player_id(标识归属玩家)等。#[auto_inc]表示entity_id由 SpacetimeDB 在插入时自动分配递增值;#[index(btree)]为player_id建立 B 树索引以加速按玩家查询。
玩家表:Identity 与唯一约束
// Rust #[spacetimedb::table(accessor = player, public)] #[derive(Debug, Clone)] pub struct Player { #[primary_key] identity: Identity, #[unique] #[auto_inc] player_id: i32, name: String, }// C# [Table(Accessor = "player", Public = true)] public partial struct Player { [PrimaryKey] public Identity identity; [Unique, AutoInc] public int player_id; public string name; }// C++ 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;Identity类型是 SpacetimeDB 用于唯一标识并认证用户的身份标识符。它在服务端表定义中用作主键,使每个玩家与自己的身份一一对应。
编写第一个 Reducer
Reducer是模块中可被客户端远程调用的函数。先写一个调试用的 reducer,打印调用者的Identity:
// Rust #[spacetimedb::reducer] pub fn debug(ctx: &ReducerContext) -> Result<(), String> { log::debug!("This reducer was called by {}.", ctx.sender()); Ok(()) }// C# [Reducer] public static void Debug(ReducerContext ctx) { Log.Info($"This reducer was called by {ctx.Sender}"); }// C++ SPACETIMEDB_REDUCER(debug, ReducerContext ctx) { LOG_INFO("This reducer was called by " + ctx.sender().to_string()); return Ok(); }该 reducer 不更新任何表,只输出调用它的客户端Identity。
Reducer 的底层语义:事务与原子性
"Reducer" 一词由 Clockwork Labs 提出,指一个在执行时把一系列插入和删除"归约"(reduce)进数据库状态的函数。该术语源自函数式编程,与 React Redux 等框架中的同名概念密切相关(可参考 Redux 关于 reducer 数据流的说明)。Reducer 可以被客户端 SDK 或 CLI 远程调用,也可以由另一个 reducer 调度到未来某个时刻执行。
所有 reducer 都是**事务性(transactionally)与原子性(atomically)**执行的:
- 从 reducer 内部看,所有改动都像是立即应用到数据库;
- 从外部看,只有在 reducer 成功完成后,改动才会真正生效;
- 如果 reducer 返回错误或在执行中 panic,数据库会回滚全部改动,如同该函数从未被调用。
对于稍复杂的应用,这一特性至关重要——它保证了业务逻辑要么全部生效、要么全部不生效。
发布模块并调用 Reducer
启动本地 SpacetimeDB
在新终端窗口中启动本地 SpacetimeDB:
spacetime start看到如下日志即表示服务已在本机运行:
Starting SpacetimeDB listening on 127.0.0.1:3000登录并发布模块
在另一个终端窗口中,进入blackholio目录。若尚未登录 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,得到的 identity 由本地服务器直接签发,丢失后不可恢复,且只被签发它的服务器识别。
调用调试 Reducer 并查看日志
spacetime call --server local blackholio debug调用成功时该命令本身没有输出,需要查看模块日志:
spacetime logs --server local blackholio可看到类似输出:
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.日志不仅验证了五张表的创建顺序,也印证了debugreducer 确实被调用并打印了调用者的 Identity。
事件型 Reducer:客户端连接与断开
SpacetimeDB 允许定义在特定事件发生时由系统自动触发的特殊 reducer。将debug改名为connect并标记为客户端连接事件:
// Rust #[spacetimedb::reducer(client_connected)] pub fn connect(ctx: &ReducerContext) -> Result<(), String> { log::debug!("{} just connected.", ctx.sender()); Ok(()) }// C# [Reducer(ReducerKind.ClientConnected)] public static void Connect(ReducerContext ctx) { Log.Info($"{ctx.Sender} just connected."); }// C++ SPACETIMEDB_CLIENT_CONNECTED(connect, ReducerContext ctx) { LOG_INFO(ctx.sender().to_string() + " just connected."); return Ok(); }client_connected(C# 为ReducerKind.ClientConnected)表示该 reducer 只会在客户端连接到数据库时被 SpacetimeDB 自身调用,调用者的 identity 位于ctx.sender()/ctx.Sender中。
SpacetimeDB 提供的事件型 reducer 共三类:
| 事件 | 触发时机 |
|---|---|
init(C#:ReducerKind.Init) | 模块首次发布时,以及使用spacetime publish --server local <name> --delete-data清空数据库重新发布时 |
client_connected(C#:ReducerKind.ClientConnected) | 用户连接到 SpacetimeDB 数据库时,其身份可从sender获取 |
client_disconnected(C#:ReducerKind.ClientDisconnected) | 用户断开与 SpacetimeDB 数据库的连接时 |
修改后重新发布模块:
spacetime publish --server local blackholio生成 Unreal 客户端代码
spacetimeCLI 内置了代码生成能力,可把模块中定义的表、类型和 reducer 生成为 Unreal C++ 类型供客户端使用。在blackholio目录执行:
spacetime generate --lang unrealcpp --uproject-dir .. --unreal-module-name blackholio参数说明:
--uproject-dir:指向包含.uproject文件的 Unreal 工程目录的路径;--unreal-module-name:Unreal 模块名,大多数工程中与工程名相同(此处为blackholio)。
生成的文件将落在blackholio/Source/blackholio/Private/ModuleBindings与blackholio/Source/blackholio/Public/ModuleBindings两个目录中,目录结构大致如下:
├── Reducers ├── Tables │ ├── CircleTable.g.h │ ├── ConfigTable.g.h │ ├── EntityTable.g.h │ ├── FoodTable.g.h │ └── PlayerTable.g.h ├── Types │ ├── CircleType.g.h │ ├── ConfigType.g.h │ ├── DbVector2Type.g.h │ ├── EntityType.g.h │ ├── FoodType.g.h │ └── PlayerType.g.h └── ReducerBase.g.h └── SpacetimeDBClient.g.h其中ModuleBindings/SpacetimeDBClient.g.h会生成一个类型感知的UDbConnection类,这是 Unreal 客户端连接数据库的核心入口。
生成绑定代码后强烈建议重启 Unreal Editor;必要时对
.uproject执行一次"Generate project files"(生成项目文件),以便 IDE 与编辑器识别新增的生成代码。
在 Unreal 客户端中连接数据库
配置模块依赖
更新blackholio.Build.cs,加入SpacetimeDbSdk与Paper2D依赖(后两个模块用于后续章节的 2D 渲染):
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "SpacetimeDbSdk", "Paper2D" }); PrivateDependencyModuleNames.AddRange(new string[] { "UMG", "SlateCore", "Slate" });C++ 路径:改造 GameManager
在GameManager.h中声明连接相关的属性与回调。注意三个可在编辑器中配置的UPROPERTY:ServerUri(默认127.0.0.1:3000)、DatabaseName(默认blackholio)以及TokenFilePath(默认.spacetime_blackholio):
#pragma once #include "CoreMinimal.h" #include "GameFramework/Actor.h" #include "ModuleBindings/SpacetimeDBClient.g.h" #include "GameManager.generated.h" class UDbConnection; UCLASS() class BLACKHOLIO_API AGameManager : public AActor { GENERATED_BODY() public: AGameManager(); static AGameManager* Instance; UPROPERTY(EditAnywhere, Category="BH|Connection") FString ServerUri = TEXT("127.0.0.1:3000"); UPROPERTY(EditAnywhere, Category="BH|Connection") FString DatabaseName = TEXT("blackholio"); UPROPERTY(EditAnywhere, Category="BH|Connection") FString TokenFilePath = TEXT(".spacetime_blackholio"); UPROPERTY(BlueprintReadOnly, Category="BH|Connection") FSpacetimeDBIdentity LocalIdentity; UPROPERTY(BlueprintReadOnly, Category="BH|Connection") UDbConnection* Conn = nullptr; UFUNCTION(BlueprintPure, Category="BH|Connection") bool IsConnected() const { return Conn != nullptr && Conn->IsActive(); } UFUNCTION(BlueprintCallable, Category="BH|Connection") void Disconnect() { if (Conn != nullptr) { Conn->Disconnect(); Conn = nullptr; } } protected: virtual void BeginPlay() override; virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; public: // Called every frame virtual void Tick(float DeltaTime) override; private: UFUNCTION() void HandleConnect(UDbConnection* InConn, FSpacetimeDBIdentity Identity, const FString& Token); UFUNCTION() void HandleConnectError(const FString& Error); UFUNCTION() void HandleDisconnect(UDbConnection* InConn, const FString& Error); UFUNCTION() void HandleSubscriptionApplied(FSubscriptionEventContext& Context); };在GameManager.cpp中实现连接生命周期。BeginPlay中使用UDbConnection::Builder()构建连接——这与仓库 SDK 中 DbConnectionBuilder.h 暴露的WithUri、WithDatabaseName、WithToken及三个回调注册接口一一对应:
#include "GameManager.h" #include "Connection/Credentials.h" AGameManager* AGameManager::Instance = nullptr; AGameManager::AGameManager() { PrimaryActorTick.bCanEverTick = true; PrimaryActorTick.bStartWithTickEnabled = true; } void AGameManager::BeginPlay() { Super::BeginPlay(); Instance = this; FOnConnectDelegate ConnectDelegate; BIND_DELEGATE_SAFE(ConnectDelegate, this, AGameManager, HandleConnect); FOnDisconnectDelegate DisconnectDelegate; BIND_DELEGATE_SAFE(DisconnectDelegate, this, AGameManager, HandleDisconnect); FOnConnectErrorDelegate ConnectErrorDelegate; BIND_DELEGATE_SAFE(ConnectErrorDelegate, this, AGameManager, HandleConnectError); UCredentials::Init(*TokenFilePath); FString Token = UCredentials::LoadToken(); UDbConnectionBuilder* Builder = UDbConnection::Builder() ->WithUri(ServerUri) ->WithDatabaseName(DatabaseName) ->OnConnect(ConnectDelegate) ->OnDisconnect(DisconnectDelegate) ->OnConnectError(ConnectErrorDelegate); if (!Token.IsEmpty()) { Builder->WithToken(Token); } Conn = Builder->Build(); } void AGameManager::EndPlay(const EEndPlayReason::Type EndPlayReason) { Disconnect(); if (Instance == this) { Instance = nullptr; } Super::EndPlay(EndPlayReason); } void AGameManager::Tick(float DeltaTime) { if (IsConnected()) { Conn->FrameTick(); } } void AGameManager::HandleConnect(UDbConnection* InConn, FSpacetimeDBIdentity Identity, const FString& Token) { UE_LOG(LogTemp, Log, TEXT("Connected.")); UCredentials::SaveToken(Token); LocalIdentity = Identity; FOnSubscriptionApplied AppliedDelegate; BIND_DELEGATE_SAFE(AppliedDelegate, this, AGameManager, HandleSubscriptionApplied); Conn->SubscriptionBuilder() ->OnApplied(AppliedDelegate) ->SubscribeToAllTables(); } void AGameManager::HandleConnectError(const FString& Error) { UE_LOG(LogTemp, Log, TEXT("Connection error %s"), *Error); } void AGameManager::HandleDisconnect(UDbConnection* InConn, const FString& Error) { UE_LOG(LogTemp, Log, TEXT("Disconnected.")); if (!Error.IsEmpty()) { UE_LOG(LogTemp, Log, TEXT("Disconnect error %s"), *Error); } } void AGameManager::HandleSubscriptionApplied(FSubscriptionEventContext& Context) { UE_LOG(LogTemp, Log, TEXT("Subscription applied!")); }连接流程要点:
- 通过回调委托注册
OnConnect/OnConnectError/OnDisconnect,并在HandleConnect中完成后续初始化。SDK 提供的 token 持久化能力对应仓库中的 Credentials.cpp 实现(Init、LoadToken、SaveToken); HandleConnect中通过SubscriptionBuilder()构建订阅并调用SubscribeToAllTables(),让 SpacetimeDB 把所有表的状态同步到客户端 SDK 的"客户端缓存"中;- 也可以只订阅特定表,使用 SQL 语法如
SELECT * FROM my_table,支持的操作集合见 SQL Reference; Tick中每帧调用Conn->FrameTick()驱动底层 WebSocket 消息处理,这是 Unreal 客户端保持连接存活的必要步骤。
Blueprint 路径:纯蓝图连接
如果选择 Blueprint 路线,首先关闭、重新构建并重新启动工程,让插件与生成的代码引用生效。然后:
- 编辑
BP_GameMode:新增变量GameManager(类型为BP Game Manager > Object Reference),并新增GetGameManager与SetGameManager两个函数用于存取该变量; - 编辑
BP_GameManager,新增Connection分类下的变量:ServerUri(String,Instance Editable,默认值127.0.0.1:3000)DatabaseName(String,Instance Editable,默认值blackholio)TokenFilePath(String,Instance Editable,默认值.spacetime_blackholio)LocalIdentity(Spacetime DBIdentity)Conn(Db Connection > Object Reference,Instance Editable)Token(String)
- 在Event BeginPlay中调用新增的
BuildConnection函数,内部通过Db Connection的构建器节点设置 URI、数据库名,并从Event Dispatchers -> Create Event为OnConnect/OnConnectError/OnDisconnect生成OnConnect_Event等事件节点; - 在
OnConnect_Event中保存 token、记录LocalIdentity,并通过SubscriptionBuilder -> OnApplied -> SubscribeToAllTables订阅全部表。
Blueprint 的连接语义与 C++ 路径完全一致:回调触发、token 持久化、订阅同步。
SDK 客户端缓存
所谓SDK 客户端缓存(client cache),是客户端侧的数据库视图,其内容由传给Subscribe函数的查询决定。SpacetimeDB 保证订阅查询的结果会随数据变化自动更新并推送到客户端缓存中,客户端因此可以高效地本地访问数据,而无需反复发起服务端查询。
验证端到端连接
一切就绪后,在 Unreal 中点击 Play。若一切正常,Unreal 日志中会出现:
UWebsocketManager::Connect: Connecting to ws://127.0.0.1:3000/v1/database/blackholio/subscribe? ... 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.这条日志正是我们在上文定义的client_connectedreducer 被触发的证据:Unreal 客户端一连接,服务端就收到了带身份标识的连接通知。
下一步
至此,你已经完成了从 Unreal 工程搭建、SpacetimeDB 服务端模块编写(表 + reducer + 事件 reducer),到客户端连接与订阅同步的完整链路。这个阶段的关键验证点就是:Unreal 客户端成功连接本地数据库,且服务端能感知连接事件。
在 Part 3 中,将继续构建游戏功能:学习如何在 Unreal 中读取表数据、调用 reducer,并实现食物生成、玩家登录与实体同步等实际玩法逻辑。仓库中的 demo/Blackholio/server-rust/src/lib.rs 提供了整个教程最终形态的 Rust 服务端完整参考实现,包含定时调度、碰撞吞噬、分裂重组等全部机制,可作为学习对照。
【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考