
SpacetimeDB Godot 教程第 4 部分用服务端定时 Reducer 实现玩家移动与碰撞吃豆【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB本教程是 SpacetimeDB Godot 系列教程的最终篇承接 第 3 部分围绕「Agar.io」风格的多人吞噬游戏 Blackholio 展开。你将学会如何在 SpacetimeDB 模块内构建一套纯服务端的 2D 物理模拟用定时 Reducer 作为游戏「Tick」推进所有实体运动、实现圆形实体间的碰撞判定与吞噬规则并让 Godot 客户端零成本地同步这些状态。读完本篇你将掌握 SpacetimeDB 定时任务Scheduled Reducer、索引过滤、主键更新/删除等核心服务端编程技巧并完成一个可部署到本地或 Maincloud 的完整可玩游戏。概述本部分要完成什么在本系列前几部分我们已经完成了服务端模块的骨架init定时生成食物、connect/disconnect登录登出、enter_game为玩家生成初始圆形。现在游戏还差最后两块拼图让玩家能移动——客户端把鼠标方向输入发给服务端服务端按固定频率模拟物理运动让玩家能吞噬——圆形实体互相重叠时按质量判定谁吃掉谁食物与对手玩家都被纳入碰撞系统。原文档的完整实现可在仓库的 demo/Blackholio 目录中找到服务端 Rust 版在 demo/Blackholio/server-rust/src/lib.rs 与 demo/Blackholio/server-rust/src/math.rsGodot 客户端在 demo/Blackholio/client-godot。一、移动玩家从客户端输入到服务端 Reducer移动的核心思路是客户端不直接控制实体坐标而是通过一个 Reducer 把「期望方向」写入数据库服务端随后在一个定时 Reducer 里读取这些方向并推进物理模拟。这种「输入上行、状态下行」的架构正是 SpacetimeDB 多人游戏的标准玩法。1.1 抽离二维向量数学库 DbVector2为了让碰撞与移动计算更方便首先把 2D 向量抽成一个独立的数学类型。Rust 版将其放入新的math.rs文件完整源码见 demo/Blackholio/server-rust/src/math.rs并在lib.rs顶部引入pub mod math; use math::DbVector2; // ...DbVector2需要能被#[derive(SpacetimeType)]派生这样才能作为表字段与 Reducer 参数跨网络传输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, }它通过标准运算符重载提供了完整的向量运算能力Add/AddAssign/Sub/SubAssign做向量加减、Mulf32做标量缩放、Divf32做除法注意除零时返回零向量避免 NaN、Sum支持迭代器求和。配套的实例方法包括new、sqr_magnitude平方模长避免开方、magnitude模长与normalized归一化。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() } }C# 版在blackholio-server/spacetimedb目录新建Math.cs用[SpacetimeDB.Type]声明结构体SqrMagnitude/Magnitude/Normalized属性加运算符重载并从Lib.cs中删除原DbVector2定义。C 版在blackholio/spacetimedb/src目录新建math.h定义struct DbVector2后通过SPACETIMEDB_STRUCT(DbVector2, x, y)注册为 SpacetimeDB 类型并在lib.cpp顶部#include math.h。1.2 写入玩家输入的 Reducerupdate_player_input新建一个 Reducer 接收来自客户端的移动方向。Rust 实现如下与 demo/Blackholio/server-rust/src/lib.rs 中update_player_input一致#[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(()) }关键点逐一拆解ctx.sender()是服务端注入的认证身份客户端无法伪造sender来操控别人的圆形。ctx.sender()由 SpacetimeDB 在验证调用者身份后自动填充因此该 Reducer 从逻辑上杜绝了「移动他人圆形」的可能——这正是服务端权威Server Authority的体现。主键查找ctx.db.player().identity().find(ctx.sender())用identity主键索引定位玩家记录找不到则返回Err(Player not found)。索引过滤ctx.db.circle().player_id().filter(player.player_id)走的是circle表上声明的#[index(btree)] player_id索引见 demo/Blackholio/server-rust/src/lib.rs 中Circle表定义只取出该玩家名下的所有圆形。输入归一化与限幅方向向量归一化后存入directionspeed取模长并clamp(0.0, 1.0)保证输入永远在有效范围内。按主键更新ctx.db.circle().entity_id().update(circle)用entity_id主键把改后的整行写回。C# 版[Reducer] public static void UpdatePlayerInput(ReducerContext ctx, DbVector2 direction)通过ctx.Db.player.identity.Find(ctx.Sender)查玩家、ctx.Db.circle.player_id.Filter(player.player_id)遍历其圆形circle.speed Math.Clamp(direction.Magnitude, 0f, 1f)后ctx.Db.circle.entity_id.Update(circle)。C 版SPACETIMEDB_REDUCER(update_player_input, ReducerContext ctx, DbVector2 direction)用ctx.db[player_identity].find(ctx.sender())查玩家、ctx.db[circle_player_id].filter(player_id)遍历圆形direction.normalized()与std::clamp(direction.magnitude(), 0.0f, 1.0f)后调用ctx.db[circle_entity_id].update(circle)。1.3 定时 Reducer把游戏 Tick 搬进数据库只有输入还不够还必须让圆形持续运动。SpacetimeDB 内置「定时 Reducer」机制只要向一张标记了scheduled的表插入一行模块就会在指定时间点自动调用对应 Reducer。这相当于把传统游戏引擎里的Update循环直接搬到了数据库内部。定义定时表MoveAllPlayersTimerRust 版#[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, }注意两点约定scheduled(move_all_players)声明这张表的行表示对move_all_playersReducer 的调度计划调度表必须有scheduled_id与scheduled_at两个字段但除此之外你可以像普通表一样追加任意业务字段。同时补充速度公式。游戏中圆形越大移动越慢原文档给出质量到最大速度的换算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()) }接下来实现真正驱动物理模拟的move_all_playersReducer。它每 50ms 被调用一次遍历所有圆形按direction * speed计算位移再乘以与质量相关的最大速度最后用clamp把坐标限制在竞技场边界内避免圆跑出world_size地图#[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(()) }需要理解的两处细节实体与圆分离的数据模型circle表保存玩家控制逻辑direction、speed、player_identity表保存物理属性position、mass。这里通过ctx.db.entity().entity_id().find(circle.entity_id)关联两表。mass_to_radius(mass) sqrt(mass)是圆形半径公式见 demo/Blackholio/server-rust/src/lib.rs 的mass_to_radius。兜底跳过若entity行已不存在圆形可能已被其他玩家吃掉直接continue避免对已删除实体做无效更新。C# 版表声明为[Table(Accessor move_all_players_timer, Scheduled nameof(MoveAllPlayers), ScheduledAt nameof(scheduled_at))]Reducer 遍历ctx.Db.circle.Iter()用ctx.Db.entity.entity_id.Find(circle.entity_id)取实体Math.Clamp(newPosition.x, circleRadius, worldSize - circleRadius)做边界约束。C 版用SPACETIMEDB_SCHEDULE(move_all_players_timer, 1, move_all_players)声明调度SPACETIMEDB_REDUCER(move_all_players, ReducerContext ctx, MoveAllPlayersTimer _timer)实现逻辑坐标约束用std::clamp。1.4 在 init 里注册 50ms 的调度计划move_all_players写好后还需要让它真正跑起来。回到initReducer插入一条ScheduleAt::Interval(50ms)的调度行ctx.db .move_all_players_timer() .try_insert(MoveAllPlayersTimer { scheduled_id: 0, scheduled_at: ScheduleAt::Interval(Duration::from_millis(50).into()), })?;在仓库参考实现中init一共注册了三个定时任务见 demo/Blackholio/server-rust/src/lib.rs 的initmove_all_players_timer每 50ms 移动一次、spawn_food_timer每 500ms 补食物、circle_decay_timer每 5s 让过大的圆形缓慢衰减质量。关于ScheduleAt的两种用法原文档明确ScheduleAt::Interval按固定间隔反复调用直到你删除该行ScheduleAt::Time在指定时刻只调用一次调用后 SpacetimeDB 会自动删除该行。调度引擎的底层实现在 crates/core/src/host/scheduler.rs对于区间型调度引擎会「以期望时刻为锚点、按区间计算下一次触发时间」忽略执行延迟与抖动next_interval_reschedule/next_interval_tick_after并跳过错过的 Tick——这意味着即使瞬时负载高导致某次 Tick 迟到也不会累积漂移保证 20Hz 的物理模拟长期稳定。该文件中的单元测试如next_interval_tick_skips_missed_ticks验证了错过 Tick 会被跳过的行为。C# 版ctx.Db.move_all_players_timer.Insert(new MoveAllPlayersTimer { scheduled_at new ScheduleAt.Interval(TimeSpan.FromMilliseconds(50)) });C 版ctx.db[move_all_players_timer].insert(MoveAllPlayersTimer{0, ScheduleAt(TimeDuration::from_millis(50))});1.5 重新发布模块并重新生成绑定完成服务端改动后需要重新发布模块并让客户端获得最新的 Reducer 绑定spacetime publish --server local blackholio --delete-dataspacetime generate --lang csharp --out-dir ../../module_bindings--delete-data会清空数据库并触发init重新插入Config与各定时表spacetime generate则为客户端生成新的UpdatePlayerInputReducer 绑定生成的客户端绑定目录见 demo/Blackholio/client-godot/module_bindings其中Reducers/UpdatePlayerInput.g.cs即为移动输入绑定。二、客户端接入在 Godot 的 _Process 中发送输入服务端就绪后只需修改客户端的PlayerController。原文档给出的_Process方法完整实现见 demo/Blackholio/client-godot/PlayerController.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); }这段代码展示了客户端输入的三个关键设计按 Q 锁定/解锁方向按下 Q 时把当前鼠标位置存为_lockInputPosition之后方向一直朝该点方便玩家在移动鼠标观察时保持行进方向。限频发送参考实现中SEND_UPDATES_PER_SEC 20即每 1/20 秒最多发送一次UpdatePlayerInputSEND_UPDATES_FREQUENCY 1f / SEND_UPDATES_PER_SEC。由于服务端每秒推进 20 次物理 Tick客户端 20Hz 的输入上报正好与之匹配既平滑又省带宽。方向换算以屏幕中心为原点鼠标相对中心的偏移除以屏幕高度的 1/3得到归一化程度可控的方向向量——鼠标越靠边缘speed越接近 1。注意完整的参考实现还额外处理了空格键分裂PlayerSplit与 S 键自杀Suicide两个 Reducer本教程第 4 部分只要求移动功能但仓库客户端代码已经把它们一起实现了可自行对照阅读。发布并运行后即可在竞技场内自由移动——到此一个「以数据库为游戏服务器」的 MMO 雏形已经跑起来了。三、碰撞与吃食物全遍历的简单碰撞检测移动只是第一步吞噬才是乐趣所在。原文档指出在 SpacetimeDB 中实现碰撞「极其简单」——只需写一个基于质量半径的is_overlapping判定函数然后在move_all_players里对每个圆形遍历所有实体检查重叠。原文也坦率地说明这种 O(n²) 的全遍历不是最高效的方案四叉树或空间哈希更快但 SpacetimeDB 足够快在这个实体规模下完全够用——简单即最佳。3.1 重叠判定基于质量半径的几何计算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 }这个函数有两个值得玩味的设计用平方距离比较先算distance_sq dx*dx dy*dy再与max_radius * max_radius比较避免了对每对实体都做一次开方数学上等价于「两圆心距离 ≤ 较大半径」。较大的圆吞较小的圆判定标准是「较小圆的圆心是否落入较大圆内」。因为半径由sqrt(mass)决定只要大圆的半径大于两圆心距离就说明小圆已经「陷入」大圆内部——这给了圆之间一定的重叠余量不会一碰就触发吞噬让视觉效果更自然。3.2 扩展 move_all_players处理食物与玩家的吞噬在原move_all_players中追加碰撞处理对每个圆形遍历竞技场内所有实体// 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 were 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 were 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);规则非常直白跳过自己entity_id相同直接continue吃食物如果重叠对象在food表中存在删除该食物的entity与food两行把它的mass加到自己身上吃对手的圆如果重叠对象是另一名玩家控制的圆且mass_ratio 对方质量 / 自己质量 0.85MINIMUM_SAFE_MASS_RATIO则删除对方的两行并吸收质量。这个 0.85 的阈值意味着除非明显比对方小否则双方可以安全擦身而过不会出现「势均力敌却互相吞并」的怪象。客户端完全无需改动。为什么因为客户端已经在订阅表Subscription上注册了OnDelete回调服务端删除实体时SpacetimeDB 会自动把删除事件同步给所有客户端场景中的节点随之被清理。食物同样如此——被吃掉后spawn_food定时 Reducer 会每秒补两次每 500ms 检查一次让地图上的食物总量始终维持在约 600 个TARGET_FOOD_COUNT 600见 demo/Blackholio/server-rust/src/lib.rs 常量定义。C# 版private const float MINIMUM_SAFE_MASS_RATIO 0.85f;IsOverlapping(Entity a, Entity b)在MoveAllPlayers中用ctx.Db.entity.Iter()内层遍历ctx.Db.food.entity_id.Find(...)判定食物、ctx.Db.circle.entity_id.Find(...)判定对手圆。C 版const float MINIMUM_SAFE_MASS_RATIO 0.85f;bool is_overlapping(const Entity a, const Entity b)在move_all_players中遍历ctx.db[entity]用ctx.db[food_entity_id].find(...)/ctx.db[circle_entity_id].find(...)判定类型delete_by_key删除被吞噬实体。3.3 发布即生效碰撞逻辑全部在服务端客户端无需重新生成绑定只需重新发布模块spacetime publish --server local blackholio注意这里不再需要--delete-data因为表结构没有变化保留数据即可继续游玩。进入游戏后你会发现食物随吃随补圆形越吃越大——一个完整的「大鱼吃小鱼」玩法就此闭环。四、部署到 Maincloud本地跑通后可以把游戏发布到 SpacetimeDB 官方的 Maincloud 云服务让其他玩家也能连接发布到 Maincloudspacetime publish --server maincloud your database name --delete-datayour database name必须是全局唯一的数据库名且只能包含内部连字符-不能有其他特殊字符。发布后还需把blackholio-server/spacetime.local.json中的数据库名改成同样的名称。在 Godot 的 Main 节点中把连接地址更新为https://maincloud.spacetimedb.com把 Main 节点中的数据库名改为your database name如需删除 Maincloud 上的数据库执行spacetime delete --server maincloud your database name至此你开发的是一个真正可联机的 MMO——任何人只要把自己的客户端指向你的 Maincloud 数据库名就能进入同一个竞技场。五、总结与后续方向回顾整个系列结合 docs/docs/00100-intro/00300-tutorials/00500-godot-tutorial 的 4 篇文档你已经掌握了一套完整的 SpacetimeDB Godot 开发链路配置 Godot 项目接入 SpacetimeDB开发、构建、发布服务端模块在模块中创建表、更新表、编写 Reducer使用init、client_connectedConnect等特殊 Reducer 初始化与登录用定时 Reducer 在模块内部实现一整套物理模拟移动、边界、碰撞、吞噬、食物补充从客户端连接数据库、调用 Reducer、订阅并同步数据再用同步数据渲染游戏对象。这套架构最令人惊叹的地方在于游戏世界完全由数据库驱动状态即事实。服务器不需要单独的物理引擎或状态同步层——所有「帧」都是数据库事务客户端只是数据库的一个视图。同时原文档也坦率地列出了当前版本的局限客户端默认假定用户名是 3Blave、没有登录菜单name列没有唯一约束玩家可以重名。注意这正是两表设计的意义所在如果你现在启动多个客户端就已经是一个极简 MMO——SpacetimeDB 可以支撑数百名玩家同场竞技不同机器上的客户端使用不同 Auth Token。要把这个原型打磨成正式游戏原文档建议的方向包括用户名选择器、聊天系统、排行榜、分裂成多个圆、更好的动画与 Shader、太空主题、对象池用于FoodController/PlayerController/CircleController。好消息是仓库中 demo/Blackholio 已经给出了包含大部分进阶特性的完整成品实现Rust 服务端demo/Blackholio/server-rust/src/lib.rs 里已经实现了分裂player_split、自动合体circle_recombine、质量衰减circle_decay、登出时保留实体logged_out_*表、吃掉实体的事件表consume_entity_event等进阶玩法通过ctx.rng()保证随机数在分布式环境下的确定性C# 服务端demo/Blackholio/server-csharpTypeScript 服务端demo/Blackholio/server-tsGodot 客户端demo/Blackholio/client-godot含PlayerSplit、Suicide、死亡结算等完整 UI 逻辑与 GodotPlayModeTests.cs 播放模式测试另有 Unity、Unreal、纯 TS 等多端客户端参考。你可以直接基于这些源码继续你的游戏之旅——毕竟在 SpacetimeDB 里从零到可玩的 MMO只需要这一套教程的篇幅。【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考