状态同步(Schema)
Colyseus 用 @colyseus/schema 描述房间的 Room.state:服务端在内存里改字段,协议层把增量补丁推给已进房的客户端,客户端 SDK 把补丁应用到本地的镜像对象上。适合「所有人都要看到的结构化盘面」——座位、坐标、布尔状态、小字典等。
官方概念与回调体系见 State Synchronization 与 State Sync Callbacks。
Schema 和自定义消息(onMessage)怎么分工
| 手段 | 方向 | 典型用途 |
|---|---|---|
Schema state | 服务端 → 全体客户端,增量、带结构 | 玩家列表、血量、位置、回合号、与 UI 绑定的字段。 |
room.send / broadcast + 客户端 onMessage | 多为事件或一次性 payload | 聊天、匹配结果、playerAction 这类「打一枪」的表现触发。 |
同一逻辑可以同时存在:例如 帧同步与游戏房 里 Player.x / y / attacking 走 Schema,playerAction 走消息,分别适合「持续权威状态」和「本帧事件」。
原则:能放进 Schema、且需要所有人长期一致的数据,优先进 state;高频、大块、只与局部表现相关的内容可改用消息或客户端本地算,避免状态树过于臃肿。
本仓库默认模型:MyRoomState / Player
事实源:服务端 MyRoomState.ts(与 game_room 默认绑定的 GameRoomExample 等房间共用)。
MyRoomState(根状态)
| 字段 | Schema 类型 | 说明 |
|---|---|---|
mySynchronizedProperty | string | 演示用字符串,验证根级字段同步。 |
frame | number | 当前逻辑帧号;在帧循环里更新(见 GameRoomExample / onFrameUpdate)。 |
players | MapSchema<Player> | 玩家表;键一般为 sessionId(与 Colyseus client.sessionId 一致)。 |
Player(players 中每一项)
| 字段 | Schema 类型 | 说明 |
|---|---|---|
sessionId | string | 会话 ID。 |
userId | string | 业务用户 ID(与鉴权 / 匹配一致)。 |
seatIndex | number | 座位号;未分配时常为 -1。 |
online | boolean | 是否在线(重连窗口内可能为 false)。 |
x, y | number | 示例中的平面坐标(权威位置,适合驱动根节点)。 |
attacking | boolean | 示例中是否处于攻击状态。 |
你在自有房间中可新增 @type(...) 字段;注意旧客户端兼容性(优先加字段、慎改语义),强类型客户端需重新跑 schema-codegen(见下文)。
服务端:怎么改才会同步
- 房间类里
state = new MyRoomState()(或你的子类),与 ColyseusRoom约定一致。 - 只有服务端应写入
state;客户端收到的是解码后的只读镜像(通过 SDK 回调驱动 UI / 逻辑即可)。 - 标量 / 字符串:直接赋值,例如
this.state.frame = frame。 MapSchema<Player>:用this.state.players.set(sessionId, playerInstance)加入或覆盖;拿到引用后改player.x = 1同样会触发同步。移除条目请用当前@colyseus/schema版本在MapSchema上提供的删除 API(如delete/remove,以类型定义为准)。
下面示例必须写在 Room 生命周期与已注册的消息回调里(this.state 仅在房间实例存活期间有效);鉴权、匹配字段等与真实 GameRoomExample 对齐的细节仍以仓库为准。
import { Room, Client } from "@colyseus/core";
import { MyRoomState, Player } from "./schema/MyRoomState";
/** 最小示意:在 onJoin / onLeave / 消息回调内改 state,客户端即可收到 Schema 增量 */
export class SchemaDemoRoom extends Room<MyRoomState> {
state = new MyRoomState();
onCreate(_options: Record<string, unknown>) {
// 创建房间时初始化根字段(可选)
this.state.mySynchronizedProperty = "room alive";
// 消息回调同属房间生命周期内:此处改 state,客户端 Player.onChange 会触发
this.onMessage("demoMove", (client, payload: { x?: number; y?: number }) => {
const p = this.state.players.get(client.sessionId);
if (!p) return;
if (typeof payload?.x === "number") p.x = payload.x;
if (typeof payload?.y === "number") p.y = payload.y;
});
}
onJoin(client: Client, options: Record<string, unknown>) {
// 玩家进房:写入 MapSchema,所有客户端会收到 players.onAdd
const p = new Player();
p.sessionId = client.sessionId;
p.userId = String(options.userId ?? "");
p.seatIndex = Number(options.seatIndex ?? -1);
p.online = true;
p.x = 0;
p.y = 0;
p.attacking = false;
this.state.players.set(client.sessionId, p);
}
onLeave(client: Client, _consented: boolean) {
// 离房:从 players 移除;若类型定义无 delete,请改用该版本 MapSchema 的 remove 等等价 API
this.state.players.delete(client.sessionId);
}
onDispose() {
// 房间销毁前释放定时器、解绑外部资源等
}
}帧同步 game_room:客户端发的是 input,服务端在 FrameSyncRoom 子类的 protected onFrameUpdate 里合并各席输入并写 this.state.players / frame,而不是单独挂一个 demoMove;完整契约见 帧同步与游戏房 与 GameRoomExample.ts。上例的 demoMove 仅用于说明「在 onCreate 注册的 onMessage 里改 Schema」这一写法。
客户端:监听根状态与 players
进房成功后,客户端通过 room.state(或各 SDK 等价对象)订阅变化。
MapSchema的onAdd:有新键(新玩家)时触发;多数 SDK 对进房当下已存在的条目也会补一次onAdd,便于初始化场景实体(以 官方 State Sync Callbacks 为准)。Player实例上的onChange:该玩家顶层字段变化时触发(适合x/y/attacking)。- 根
state的onChange:根上frame、mySynchronizedProperty等变化时触发。
下面与 帧同步与游戏房 中客户端示例一致,便于对照 game_room。
import Colyseus from "colyseus.js";
/** 假设已 joinOrCreate / joinById 得到 room,且房间 state 为 MyRoomState */
function bindGameRoomState(room: Colyseus.Room) {
// 根级字段(逻辑帧、演示字符串)
room.state.onChange(() => {
console.log("frame=", room.state.frame, "prop=", room.state.mySynchronizedProperty);
});
// 玩家表:新增 / 删除
room.state.players.onAdd((player, sessionId) => {
console.log("player added", sessionId, player.x, player.y);
player.onChange(() => {
// 该 Player 上任意 @type 顶层字段变化都会进这里
console.log("player state", sessionId, player.x, player.y, player.attacking, player.online);
});
});
room.state.players.onRemove((player, sessionId) => {
console.log("player removed", sessionId);
});
}using System;
using Colyseus;
using Colyseus.Schema;
using UnityEngine;
/// <summary>
/// 强类型监听需先用 schema-codegen 根据服务端 MyRoomState.ts 生成 C# 的 MyRoomState / Player,
/// 再将 JoinOrCreate 泛型从 DynamicSchema 改为 MyRoomState。此处展示「生成后」的常见写法。
/// </summary>
public static void BindGameRoomState(Room<MyRoomState> room)
{
room.State.players.OnAdd += (sessionId, player) =>
{
Debug.Log($"player added {sessionId}");
player.OnChange += changes =>
{
// changes 含本次变更字段列表,亦可直接读 player.x / player.y
Debug.Log($"player {sessionId} x={player.x} y={player.y} attacking={player.attacking}");
};
};
room.State.players.OnRemove += (sessionId, player) => { Debug.Log($"player removed {sessionId}"); };
room.State.OnChange += () => { Debug.Log($"frame={room.State.frame}"); };
}# Godot 4 + 官方 Colyseus GDExtension:进房并 room.joined 后取 Callbacks(与官方 Quick Example 一致)
# https://docs.colyseus.io/getting-started/godot
var _callbacks: Colyseus.Callbacks
func bind_game_room_state(room: Colyseus.Room) -> void:
_callbacks = Colyseus.Callbacks.of(room)
_callbacks.listen("frame", func(current, _previous): print("frame=", current))
_callbacks.listen(
"mySynchronizedProperty",
func(current, _previous): print("mySynchronizedProperty=", current)
)
_callbacks.on_add("players", func(player, session_id: String):
print("player added ", session_id)
# 该 Player 任意顶层 @type 字段变化时触发
_callbacks.on_change(
player,
func(): print("player ", session_id, " x=", player.x, " y=", player.y, " attacking=", player.attacking)
)
# 若只需监听单一字段,可用:_callbacks.listen(player, "x", func(c, _p): ...)
)
_callbacks.on_remove("players", func(_player, session_id: String):
print("player removed ", session_id)
)Unity 若暂未生成 Schema,可用 JoinOrCreate<DynamicSchema> 先跑通消息层,再按 Unity 接入 与官方文档做 codegen 后替换为 Room<MyRoomState>。Godot 若 API 与上表不完全一致,请以当前安装的 Colyseus 插件文档为准,并对照 State Sync Callbacks · Godot。
@type 与容器:常见注意点
- 只同步带
@type(或@type({ map: ... })等)声明的字段;普通类属性不会进协议。 MapSchema/ArraySchema的键、元素类型需与装饰器声明一致,否则易出现解码错误。- 嵌套 Schema 适合实体;过深的树 + 极高频写入会增加补丁流量,可拆消息或降频写
state。 - 删除玩家时务必从
players移除或标记为离线(由你的房间逻辑决定),客户端才好onRemove回收节点。
Schema 代码生成(客户端强类型)
在 C# / GDScript(强类型)/ C++ 等侧,客户端类型需与服务端 AST 一致,一般用官方工具从服务端 Schema 源文件生成:
npx schema-codegen src/rooms/schema/MyRoomState.ts --csharp --output ./client-csharp/ --namespace MyGame.Schema
# Godot GDScript 输出示例:
# npx schema-codegen src/rooms/schema/MyRoomState.ts --gdscript --output ./addons/my_game/schema/更多参数见 State Sync Callbacks · Frontend Schema Generation。
实践建议(小结)
- 只把需要所有人一致、且结构稳定的数据放进
state。 - 爆发日志、大 JSON、纯表现可改用
broadcast+onMessage。 - 与帧同步同房间时:权威坐标、血条等放 Schema;单帧音效/特效触发可配合
playerAction类消息(见 帧同步与游戏房)。 - 上线前:跑通 codegen、在目标引擎上验证
onAdd/onChange/ 重连后进房` 是否都能对齐表现。
延伸阅读
- 帧同步与游戏房(
input、frame、playerAction与 Schema 的关系) - 房间总览
- Unity 接入 · Godot 接入 · Cocos Creator
- 官方:Schema Definition · State Sync Callbacks