拓冰建站拓冰建站
首页 / 资讯中心 / 正文

SpacetimeDB Unity 教程(四):用调度 Reducer 在数据库内实现移动、碰撞与进食

SpacetimeDB Unity 教程四用调度 Reducer 在数据库内实现移动、碰撞与进食【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB本教程是 SpacetimeDB 官方 Unity 系列教程的第四部分紧接 Part 3游戏玩法与数据同步 的内容主题是把一个看得见却动不了的吃豆球原型升级为可以自由移动、碰撞、进食成长的完整游戏循环。读完本文你将掌握如何用SpacetimeType自定义类型构建服务端向量数学库、如何通过 reducer 接收并校验玩家输入、如何借助调度表scheduled table让模块每隔 50 毫秒自动执行一次物理模拟 Tick以及如何把碰撞/进食逻辑全部放在服务端、让客户端只做渲染同步。本教程的完整可运行实现位于仓库 demo/Blackholio/server-rust/src/lib.rs 与 demo/Blackholio/server-csharp/Lib.cs。教程背景与前置条件在 Part 3 结束时我们已经完成了一个由 SpacetimeDB 驱动的 Unity 客户端服务器模块会定期在 1000×1000 的竞技场内生成约 600 份食物玩家通过enter_gamereducer 加入游戏并获得一个初始质量为 15 的圆形实体客户端通过订阅 OnInsert/OnUpdate/OnDelete回调把数据库行映射成场景中的GameObject详见 Part 3 的Hooking up the Data一节。但此时游戏还无法游玩——玩家不能移动。Part 4 要解决三件事移动客户端把输入方向发给服务器服务器据此更新每个圆形的direction与speedTick 模拟通过调度表让move_all_playersreducer 每 50ms 运行一次把圆形沿其方向推进一小步并做边界钳制碰撞与进食在同一个 Tick 里遍历所有实体做重叠检测吃到食物或吞掉更小的玩家圆形。整个过程遵循一个核心原则游戏逻辑全部在服务器模块数据库内执行客户端只负责发送输入和渲染同步结果。这既是 SpacetimeDB 的典型用法也是保证权威性anti-cheat和多人一致性的基础。一、服务端向量数学库把DbVector2变成可计算的类型碰撞计算、位移推进都离不开二维向量运算。教程的第一步是为服务端模块建立一个简单的 2D 向量库。DbVector2需要同时满足两个要求它是可存储在表中、可通过#[spacetimedb::table]引用的自定义类型因此必须派生SpacetimeTypeRust/ 标注[SpacetimeDB.Type]C#/ 调用SPACETIMEDB_STRUCTC它要支持加减乘除、求模长、归一化等数学运算供模拟逻辑使用。Rust 版本math.rs在blackholio-server/spacetimedb/src目录新建math.rs并把原来放在lib.rs里的DbVector2定义迁移进来仓库中的成品实现见 demo/Blackholio/server-rust/src/math.rsuse 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::AddDbVector2 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::AddDbVector2 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::AddAssignDbVector2 for DbVector2 { fn add_assign(mut self, rhs: DbVector2) { self.x rhs.x; self.y rhs.y; } } impl std::iter::SumDbVector2 for DbVector2 { fn sumI: IteratorItem DbVector2(iter: I) - Self { let mut r DbVector2::new(0.0, 0.0); for val in iter { r val; } r } } impl std::ops::SubDbVector2 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::SubDbVector2 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::SubAssignDbVector2 for DbVector2 { fn sub_assign(mut self, rhs: DbVector2) { self.x - rhs.x; self.y - rhs.y; } } impl std::ops::Mulf32 for DbVector2 { type Output DbVector2; fn mul(self, other: f32) - DbVector2 { DbVector2 { x: self.x * other, y: self.y * other, } } } impl std::ops::Divf32 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() } }几个值得注意的细节实现Sum迭代器仓库完整版 lib.rs 中的calculate_center_of_mass正是用entities.iter().map(|e| e.position * e.mass as f32).sum()计算质心没有Sum实现这段代码就无法编译Div对除零的防御normalized()内部调用self / self.magnitude()除零时返回(0,0)而不是产生 NaN/Inf保证零向量归一化安全sqr_magnitude的存在碰撞检测里用平方距离比较可以省去一次sqrt是常见的性能优化手法。然后需要在lib.rs顶部引入这个模块从源码看正式成品就是 lib.rs 的第一、二行pub mod math; use math::DbVector2; // ...C# 版本Math.cs在blackholio-server/spacetimedb目录新建Math.cs把DbVector2定义从Lib.cs迁移过去成品见 demo/Blackholio/server-csharp/DbVector2.cs[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); }C 版本math.h在blackholio/spacetimedb/src目录新建math.h并把DbVector2从lib.cpp迁移进来#pragma once #include spacetimedb.h #include cmath 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);同时在lib.cpp顶部引入该头文件并删除lib.cpp中的旧定义#include spacetimedb.h #include math.h // ...C 中SPACETIMEDB_STRUCT(DbVector2, x, y)与SPACETIMEDB_TABLE一样是让自定义类型能够进入数据库 schema 的关键宏。二、update_player_inputreducer接收输入并更新玩家圆形有了向量类型就可以写 reducer 接收客户端的移动输入了。该 reducer 的参数只有一个DbVector2 direction含义是玩家希望朝哪个方向、以多大力气移动客户端会按 20 次/秒的频率发送。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(()) }该 reducer 在仓库成品中的位置是 demo/Blackholio/server-rust/src/lib.rs 的 update_player_input。C# 版本[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); } }成品见 demo/Blackholio/server-csharp/Lib.cs 的 UpdatePlayerInput。C 版本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(); }三个语言版本共同的安全关键点教程原文特别强调了一个反作弊设计这个 reducer 之所以不可能移动其他玩家的圆形是因为它把输入绑定到了ctx.sender()/ctx.Sender()——该值不是客户端可以伪造的参数而是 SpacetimeDB 在完成调用者身份认证之后注入到 reducer 上下文中的。当 reducer 被调用时可以确信调用者已经通过该身份认证。具体来说direction.normalized()只保留方向把原始输入的长度归一化防止玩家通过发送超大向量瞬移direction.magnitude().clamp(0.0, 1.0)Rust 为clamp(0.0, 1.0)把速度限制在[0,1]区间配合后面mass_to_max_move_speed的质量-速度曲线最终移动速度由服务器计算而非客户端决定通过player_id().filter(...)只更新当前玩家拥有的圆形行写入后再用entity_id().update(...)持久化。三、调度表与游戏 Tick让move_all_players每 50ms 运行一次移动逻辑写完还差一步谁来推进圆形答案是利用 SpacetimeDB 的调度 reducer机制——在 Part 3 里我们已用同样方式让spawn_food每 500ms 补充食物。这里再建一张MoveAllPlayersTimer调度表把move_all_playersreducer 设为每 50 毫秒执行一次。定义调度表Rust成品见 lib.rs 的 MoveAllPlayersTimer#[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, }C#[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; }Cstruct 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);调度表的要求与SpawnFoodTimer完全一致必须包含scheduled_id主键、自增和scheduled_at字段scheduled(...)/Scheduled nameof(...)/SPACETIMEDB_SCHEDULE指明该表的行对应哪个 reducer 的调用计划。C 中SPACETIMEDB_SCHEDULE的第二个参数1是scheduled_at字段在该表中的 0 基列索引。向该表插入、删除、更新行就等于创建、取消、修改一次调度——调度本身就是普通表数据。速度公式与 Tick 主体定义玩家速度相关常量与质量→速度换算函数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()) }C# 对应const int START_PLAYER_SPEED 10;与MassToMaxMoveSpeed见 Lib.cs。然后实现move_all_players。以 Rust 版本为例成品见 lib.rs 的 move_all_players#[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(()) }C# 版本[Reducer] public static void MoveAllPlayers(ReducerContext ctx, MoveAllPlayersTimer timer) { 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 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 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); } }C 版本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_castfloat(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(); }这个 reducer 非常接近传统游戏服务器里的tick或帧就像 Unity 的Update循环一样我们把它调度为每 50 毫秒执行一次每次把所有圆形沿它们当前方向推进一小步。其中continue分支如果circle.entity_id对应的实体已不存在比如刚被别的玩家吃掉直接跳过避免悬空引用边界钳制新位置被clamp在[circle_radius, world_size - circle_radius]之间即圆形的边缘永远不会越出竞技场边界且半径越大的圆形可活动的范围越小基本物理new_pos position direction * speed * mass_to_max_move_speed(mass)质量越大、最大移动速度越小这构成了吃豆玩法中越大越慢的经典手感来源。在initreducer 中注册调度Rust成品见 lib.rs 的 initctx.db .move_all_players_timer() .try_insert(MoveAllPlayersTimer { scheduled_id: 0, scheduled_at: ScheduleAt::Interval(Duration::from_millis(50).into()), })?;C#ctx.Db.move_all_players_timer.Insert(new MoveAllPlayersTimer { scheduled_at new ScheduleAt.Interval(TimeSpan.FromMilliseconds(50)) });Cctx.db[move_all_players_timer].insert(MoveAllPlayersTimer{ 0, ScheduleAt(TimeDuration::from_millis(50)), });ScheduleAt::Interval表示按固定间隔反复执行直到该行被删除若想只执行一次可以用ScheduleAt::Time(timestamp)执行后行会被自动移除。在本模块中move_all_players_timer由initreducer 插入一次即可永久运行这与spawn_food_timer500ms 补食物、circle_decay_timer每 5 秒质量衰减等调度在 lib.rs 的 init 中一同注册。重新发布模块并重新生成绑定spacetime publish --server local blackholio --delete-data--delete-data会清空数据库并重新触发initreducer从而重建调度。随后重新生成 C# 客户端绑定spacetime generate --lang csharp --out-dir ../../module_bindings四、客户端把鼠标位置换算成方向并调用 reducer服务端就绪后客户端只需要在PlayerController中新增Update函数把鼠标相对屏幕中心的位置换算成方向向量再节流发送给服务器成品 Unity 工程位于仓库 demo/Blackholio/client-unity以下为教程中的PlayerController.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); } }要点解读IsLocalPlayer与NumberOfOwnedCircles守卫非本机玩家或已无圆形的玩家不发送输入Q键锁定输入按Q可以把鼠标位置锁定为固定的移动方向松开再按恢复跟随鼠标即定向往某方向操作LockInputPosition为空时退回实时鼠标位置节流SEND_UPDATES_FREQUENCY来自 Part 3 中SEND_UPDATES_PER_SEC 20即每 50ms 一次防止以显示器刷新率频率刷屏调用 reducer坐标换算(mousePosition - centerOfScreen) / (screenSize.y / 3)把鼠标偏离屏幕中心的像素归一化为一个模长量级合理的方向向量——远离中心意味着方向向量更长服务器端update_player_input会取它的Magnitude作为speed从而鼠标离中心越远移动越快testInputEnabled/testInput是教程预留的自动化测试钩子。可能遇到的坑Input System如果编译报错提示使用了错误的 Input System请打开 Unity 的Edit - Project Settings...左侧选择Player滚动到Other Settings - Configuration找到Active Input Handling切换为Input Manager (Old)或Both随后 Unity 会提示重启编辑器以生效。按 Play 键你现在就可以在竞技场里自由移动了。五、碰撞与进食全服务端实现的IsOverlapping 吞噬逻辑能移动之后下一步是吃。教程的路线是新增一个IsOverlapping重叠检测辅助函数并改造move_all_playersreducer——对每个圆形遍历竞技场内所有实体检查是否重叠重叠时根据对方是食物还是玩家圆形分别处理。教程原文也坦承对每个圆对每个实体做 O(n²) 检测并非最高效方案更优的做法是四叉树或[空间哈希]但 SpacetimeDB 的执行速度足以支撑这个数量级的实体简单就是最好。重叠判定基于质量半径的圆-圆检测Rustconst 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 }成品见 lib.rs 的 is_overlapping。C#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 distanceSq dx * dx dy * dy; 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 maxRadius aRadius bRadius ? aRadius: bRadius; return distanceSq maxRadius * maxRadius; }Cconst 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; }其中质量→半径的公式是mass_to_radius(mass) sqrt(mass)面积与质量成正比圆面积 ∝ r²。判定逻辑值得展开max_radius而非半径之和只要小圆的圆心落进大圆内部就算重叠。这给了圆形在被吃之前一定的重叠余量视觉效果上更宽容distance_sq max_radius * max_radius用平方距离比较避免一次sqrt仓库完整版 lib.rs 的 IsOverlapping 在此基础上略有演进用半径之和乘(1 - MIN_OVERLAP_PCT_TO_CONSUME)控制重叠比例说明这是可以按手感微调的部分。改造move_all_players移动 碰撞 吞噬把碰撞检测并进 Tick。以 Rust 版本为例完整成品见 lib.rs 的 move_all_players教程正文给出的简化版如下#[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 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); } Ok(()) }C# 版本与 C 版本结构完全一致见教程正文及 Lib.cs。业务规则总结为一张表重叠对象判定条件结果食物实体存在于food表删除食物实体 食物行circle_entity.mass entity.mass其他玩家的圆形other_circle.player_id ! circle.player_id且mass_ratio 0.85删除对方实体 圆形行circle_entity.mass entity.mass其他玩家的圆形mass_ratio 0.85势均力敌不做处理谁也吃不掉谁自己的圆形 / 自己跳过——两个容易被忽视的设计点MINIMUM_SAFE_MASS_RATIO 0.85只有当对方质量不足自己质量的 85% 时才能吞噬。这防止了两个大小相近的圆形无限互相吞的死锁也避免了贴身肉搏时双方同时删掉对方删除顺序被吃对象同时存在于entity表和food/circle表中必须把两处行都删除否则会产生幽灵实体实体仍在但无类型归属。仓库完整版把这个职责收敛为destroy_entity辅助函数见 lib.rs 的 destroy_entity并进一步演进出schedule_consume_entityconsume_entityreducer把吞噬延迟到下一帧执行以避免一帧内连锁修改——这是对教程简化版的健壮性升级。客户端零改动同步是自动的关键洞察在于——做完这一切客户端不需要改任何代码。因为服务端删除/更新行时SpacetimeDB 会自动把增量变更推送给所有订阅了相关表的客户端客户端在 Part 3 中已经注册了EntityOnDelete/FoodOnDelete/CircleOnDelete等回调删除事件到达时场景中的GameObject会被自动销毁EntityController.OnDelete里的Destroy(gameObject)见 Part 3 的 EntityController圆形的缩放由OnEntityUpdated根据MassToScale(newVal.Mass)插值更新。发布即可生效spacetime publish --server local blackholio这次无需--delete-data因为表结构没变只是逻辑更新。你会发现食物被吃掉后会自动补满到接近 600 份这正是 Part 3 中spawn_food调度每 500ms、目标 600 份的功劳。六、连接 Maincloud把游戏部署到云端本地验证通过后可以发布到 SpacetimeDB 的 Maincloud 托管服务发布到 Maincloud首次带上--delete-data以全新初始化spacetime publish --server maincloud your database name --delete-datayour database name必须是唯一名称且除内部连字符-外不能包含特殊字符。同时要把blackholio-server/spacetime.local.json中的数据库名同步改为该名称。更新 Unity 工程中的连接配置GameManager.csconst string SERVER_URL https://maincloud.spacetimedb.com; const string DATABASE_NAME your database name;清理缓存的连接数据在Start()中删除PlayerPrefsprivate void Start() { // Clear cached connection data to ensure proper connection PlayerPrefs.DeleteAll(); // Continue with initialization }删除云端数据库spacetime delete --server maincloud your database name七、总结你已经拥有了一个简化版MMO到此为止整个教程系列的核心能力已经齐备模块侧创建/更新表、编写 reducer、使用init/client_connected等特殊 reducer、用调度表在模块内实现周期性物理模拟客户端侧连接数据库服务器、从客户端调用 reducer、订阅并同步表数据、用同步数据在屏幕上绘制可交互的游戏对象。正如教程结尾所说尽管客户端仍把玩家名硬编码为 3Blavename列也没有唯一约束但这并不妨碍多人同时连接如果你构建当前代码并运行多个客户端你已经拥有了一个非常简单的 MMO——同一个竞技场里可以容纳成百上千名玩家前提是不同客户端运行在不同机器上以获得不同的身份令牌。如果继续打磨可以往这些方向扩展用户名选择器、聊天、排行榜、圆形分裂、更好的动画与着色器、太空主题、以及针对FoodController/PlayerController/CircleController的对象池。仓库中 demo/Blackholio 提供了带多数扩展功能的完整成品游戏Rust / C# / TypeScript / C 服务端与 Unity / Godot / Unreal / TS 客户端齐全其中服务端核心逻辑正是本文讲解的update_player_inputmove_all_playersconsume_entity组合值得对照阅读Rust 版见 server-rust/src/lib.rs 与 server-rust/src/math.rsC# 版见 server-csharp/Lib.cs 与 server-csharp/DbVector2.cs。完整的教程分步版本可参考 Part 1、Part 2 与 Part 3。【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门