Skip to content

状态同步(Schema)

Colyseus 用 @colyseus/schema 描述房间的 Room.state:服务端在内存里改字段,协议层把增量补丁推给已进房的客户端,客户端 SDK 把补丁应用到本地的镜像对象上。适合「所有人都要看到的结构化盘面」——座位、坐标、布尔状态、小字典等。

官方概念与回调体系见 State SynchronizationState 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 类型说明
mySynchronizedPropertystring演示用字符串,验证根级字段同步。
framenumber当前逻辑帧号;在帧循环里更新(见 GameRoomExample / onFrameUpdate)。
playersMapSchema<Player>玩家表;键一般为 sessionId(与 Colyseus client.sessionId 一致)。

Playerplayers 中每一项)

字段Schema 类型说明
sessionIdstring会话 ID。
userIdstring业务用户 ID(与鉴权 / 匹配一致)。
seatIndexnumber座位号;未分配时常为 -1
onlineboolean是否在线(重连窗口内可能为 false)。
x, ynumber示例中的平面坐标(权威位置,适合驱动根节点)。
attackingboolean示例中是否处于攻击状态。

你在自有房间中可新增 @type(...) 字段;注意旧客户端兼容性(优先加字段、慎改语义),强类型客户端需重新跑 schema-codegen(见下文)。

服务端:怎么改才会同步

  1. 房间类里 state = new MyRoomState()(或你的子类),与 Colyseus Room 约定一致。
  2. 只有服务端应写入 state;客户端收到的是解码后的只读镜像(通过 SDK 回调驱动 UI / 逻辑即可)。
  3. 标量 / 字符串:直接赋值,例如 this.state.frame = frame
  4. MapSchema<Player>:用 this.state.players.set(sessionId, playerInstance) 加入或覆盖;拿到引用后改 player.x = 1 同样会触发同步。移除条目请用当前 @colyseus/schema 版本在 MapSchema 上提供的删除 API(如 delete / remove,以类型定义为准)。

下面示例必须写在 Room 生命周期与已注册的消息回调里this.state 仅在房间实例存活期间有效);鉴权、匹配字段等与真实 GameRoomExample 对齐的细节仍以仓库为准。

ts
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 等价对象)订阅变化。

  • MapSchemaonAdd:有新键(新玩家)时触发;多数 SDK 对进房当下已存在的条目也会补一次 onAdd,便于初始化场景实体(以 官方 State Sync Callbacks 为准)。
  • Player 实例上的 onChange:该玩家顶层字段变化时触发(适合 x/y/attacking)。
  • stateonChange:根上 framemySynchronizedProperty 等变化时触发。

下面与 帧同步与游戏房 中客户端示例一致,便于对照 game_room

ts
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);
  });
}
csharp
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}"); };
}
gdscript
# 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 源文件生成:

bash
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 / 重连后进房` 是否都能对齐表现。

延伸阅读