news 2026/9/12 14:10:20

SpacetimeDB Unreal 教程 Part 2:从零搭建服务端模块并打通 Unreal 客户端连接

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpacetimeDB Unreal 教程 Part 2:从零搭建服务端模块并打通 Unreal 客户端连接

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 表

本节需要编辑的模块文件根据语言不同:

语言编辑文件
Rustblackholio/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以保证跨语言一致;如果你更喜欢camelCasePascalCase也可以使用。

关于主键需要特别注意:主键定义了行的"身份"。修改一行但不动主键,属于更新;一旦改动主键,则相当于删除旧行并插入新行。

关于表的更多定义方式(索引、约束、列类型),可查阅 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);

随后定义三张实体表entitycirclefood

// 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表代表游戏世界中的一个对象,统一携带positionmass两个公共字段;
  • 需要额外数据的实体类型,通过"新表 + 引用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/ModuleBindingsblackholio/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,加入SpacetimeDbSdkPaper2D依赖(后两个模块用于后续章节的 2D 渲染):

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "SpacetimeDbSdk", "Paper2D" }); PrivateDependencyModuleNames.AddRange(new string[] { "UMG", "SlateCore", "Slate" });

C++ 路径:改造 GameManager

GameManager.h中声明连接相关的属性与回调。注意三个可在编辑器中配置的UPROPERTYServerUri(默认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 暴露的WithUriWithDatabaseNameWithToken及三个回调注册接口一一对应:

#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 实现(InitLoadTokenSaveToken);
  • HandleConnect中通过SubscriptionBuilder()构建订阅并调用SubscribeToAllTables(),让 SpacetimeDB 把所有表的状态同步到客户端 SDK 的"客户端缓存"中;
  • 也可以只订阅特定表,使用 SQL 语法如SELECT * FROM my_table,支持的操作集合见 SQL Reference;
  • Tick中每帧调用Conn->FrameTick()驱动底层 WebSocket 消息处理,这是 Unreal 客户端保持连接存活的必要步骤。

Blueprint 路径:纯蓝图连接

如果选择 Blueprint 路线,首先关闭、重新构建并重新启动工程,让插件与生成的代码引用生效。然后:

  1. 编辑BP_GameMode:新增变量GameManager(类型为BP Game Manager > Object Reference),并新增GetGameManagerSetGameManager两个函数用于存取该变量;
  2. 编辑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)
  3. Event BeginPlay中调用新增的BuildConnection函数,内部通过Db Connection的构建器节点设置 URI、数据库名,并从Event Dispatchers -> Create EventOnConnect/OnConnectError/OnDisconnect生成OnConnect_Event等事件节点;
  4. 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 14:10:11

AI视频工具选型指南:小云雀、即梦、可灵、PixVerse深度对比

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 14:08:50

JSP文件上传插件开发:拖拽上传与局域网优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 14:08:18

基于Arnold与Logistic的MATLAB图像加解密及传输仿真解析

简介&#xff1a;基于MATLAB的图像加解密及传输仿真课程设计资源包&#xff0c;面向电子信息、通信工程、计算机等专业在校生&#xff0c;可用于课程设计、大作业或毕业设计选题。资源针对图像加密、解密及传输链路仿真需求&#xff0c;提供完整可运行的MATLAB源码、标准测试图…

作者头像 李华