Skip to content

帧同步与游戏房

客户端进房名 game_room。其中 应作为「稳定契约」来理解 的是 FrameSyncRoom + input 消息 + 固定帧 tick + Schema 同步——与具体派生类名无关。

默认实现是内置示例类 GameRoomExamplesrc/rooms/GameRoomExample.ts):在 app.config.ts 里把 game_room 注册为它,便于演示、静态页与匹配联调。下文表格里的 playerAction 形状、move/attack/jump 语义,均以 GameRoomExample 源码为准;上线玩法请 fork / 改名 为自有 Room,勿把示例类当成不可替换的产品内核。

GameRoomExample 继承 FrameSyncRoom,在固定帧率下收集各客户端 input、驱动 onFrameUpdate / onFrameSync,并通过 Schema(MyRoomState)与广播消息同步表现。该房间通常由 匹配与开房 在组局成功后创建,也可在开发阶段自行 joinOrCreate("game_room", options) 调试。

创建选项(onCreate / define

app.config.tsgame_room 默认传入:

  • fps:目标帧率,默认 20
  • recordFrames:是否记录帧数据(回放用),默认 false

匹配创建房间时还会传入 matchIdplayersPerMatchqueueKeypartyId 等;其中 playersPerMatch 会写入 maxClients,以约束一局人数上限。

加入房间(onJoin

除 JWT 外,推荐携带匹配下发的字段(与 match:found.joinOptions 对齐):

字段说明
matchId对局 ID,用于重连校验与 Redis 重连键命名空间。
seatIndex座位号。
reconnectKey与断线时服务端生成的密钥一致时,可恢复同一逻辑玩家。
demoUserId可选;用于同一 JWT 多开测试时区分不同玩家 ID。

加入成功后,服务端可能下发:

  • frameSynccurrentFrametargetFPS
  • reconnect:ok:重连或去重成功后。
  • 其它客户端会收到 playerJoined 等(见下文)。

客户端 → 服务端消息

input(帧输入)

FrameSyncRoom 注册,payload 建议包含:

  • inputs:任意结构,由游戏逻辑解释(示例中支持 moveattackjump)。
  • frame:可选,指定输入所属帧;不传则使用服务端当前帧。

服务端每帧合并各 clientId 的最新一条待处理输入,在 onFrameUpdate 中交给子类实现的输入处理(默认示例中为 GameRoomExample 内的 processPlayerInput)。

下行:移动、攻击等结果从哪来(与 input 的关系)

input 是客户端 → 服务端的「操作意图」,服务端在 onFrameUpdate 里消费后,不会再以同名消息把您的 input 原样广播给所有人。您也不要在客户端写 onMessage("input", …) 来等自己的摇杆数据——那一路是上行。

内置示例 GameRoomExampleprocessPlayerInput 中做了两件事,供各端表现其它玩家与权威状态(您二开后的房间可另定规则):

  1. 更新 SchemaMyRoomState / Player):例如 player.xplayer.yplayer.attacking 会随逻辑帧变化。所有在房内的客户端都会通过 Colyseus 状态同步 收到增量。适合:血条、位置插值、与 UI 绑定的数据。字段见 状态同步(Schema)
  2. 广播 playerActionmove 仅在坐标实际改变时广播;jump 在本帧 inputs.jump === true 时广播;attack 则在本帧 inputs 带有 attack!== undefined)时即更新并广播,未做边沿检测——若客户端每帧都发 attack: true/false,可能每帧都收到一条 playerAction。便于特效、音效、非 Schema 的轻量表现。当前示例 payload 形态如下(与 src/rooms/GameRoomExample.ts 一致;若您已替换 game_room 绑定的类,以您仓库为准):
action说明data 示例
moveinputs.move 导致 x/y 变化{ x, y, move }move 为本次输入向量)
attack当帧 inputsattack 键(非 undefined{ attacking: boolean }
jumpinputs.jump === true 的当帧{ jump: true }

共同字段:sessionId(与 Colyseus 的 client.sessionId 一致,用于区分操作者)。

推荐做法:以 Schema 为权威位置 驱动角色根节点;playerAction 作补间、打击感、预测纠偏的触发。若您完全锁步且只信消息,也可只消费 playerAction(需自行保证与状态不冲突)。

完整示例:服务端与客户端

下列组合即本仓库中的「端到端」帧同步演示:服务端以源文件为准;客户端可用内置页面或自写脚本,二者协议一致(input + Schema + playerAction 等)。

服务端(事实源)

路径职责
src/utils/FrameSync.tsFrameSyncRoom:注册 onMessage("input")initFrameSync / startFrameSyncFrameSyncManagertargetFPS 调用 onFrameUpdate
src/rooms/schema/MyRoomState.tsMyRoomStatePlayer Schema
src/rooms/GameRoomExample.ts完整对局示例onFrameUpdateprocessPlayerInput、重连、playerActionchat
src/app.config.tsgameServer.define("game_room", GameRoomExample, { fps: 20, recordFrames: false })

继承 FrameSyncRoom 的子类至少需要:onCreateinitFrameSync + startFrameSynconDisposestopFrameSync,并实现 onFrameUpdate / onFrameSync;进房鉴权、玩家写入 state.players 等见 GameRoomExample

谁在调用 onFrameUpdate / onFrameSync 不是 Colyseus 自带的 Room 基类,而是本仓库 FrameSyncManager:在 startFrameSync() 里用 setIntervaltargetFPS 周期执行其私有方法 tick()tick 末尾依次调用管理器上的两个回调。FrameSyncRoom.initFrameSync() 会把这两个回调重写成转发到当前房间实例——即执行你在子类里重写protected onFrameUpdateprotected onFrameSync。因此无需、也不应在子类里手写 super.onFrameUpdate(...) 来触发帧循环;只要 initFrameSync + startFrameSync 已调用,由定时器驱动即可。

每帧(FrameSyncManager.tick)顺序(以 FrameSync.ts 为准):从 pendingInputs 取出本 tick 内各客户端已合并的输入 → 组装 FrameDataframetimestampinputs)→ 若 recordFrames === true 则把该快照追加到内存环形缓冲(用于回放)→ 调用 onFrameUpdate(frame, inputs) → 调用 onFrameSync(frame, frameData)currentFrame++。两回调收到的 frame 相同玩法与权威状态应写在 onFrameUpdateonFrameSync 适合同一帧上的补充同步、调试字段或与 recordFrames 快照对齐的逻辑。

下面为最小骨架(省略与帧无关的重连、聊天等,完整逻辑仍以 GameRoomExample 为准):

ts
import { Client } from "@colyseus/core";
import { FrameSyncRoom, ClientInput, FrameData } from "../utils/FrameSync";
import { MyRoomState } from "./schema/MyRoomState";
import { RequireAuth } from "../utils/decorators/RequireAuth";

export class MyFrameGame extends FrameSyncRoom<MyRoomState> {
  state = new MyRoomState();

  onCreate(options: any) {
    // 与 app.config define 传入的 fps / recordFrames 对齐;管理器按 targetFPS 定时 tick
    this.initFrameSync({
      targetFPS: options.fps ?? 20,
      enabled: true,
      recordFrames: !!options.recordFrames,
      // maxFrameDelay 等其它字段有默认值,见 FrameSyncConfig
    });
    this.startFrameSync(); // 开始 setInterval 驱动;务必在 onDispose 里 stopFrameSync
  }

  @RequireAuth()
  onJoin(client: Client, options: any) {
    // 创建 Player、写入 state.players、下发 client.send("frameSync", { currentFrame, targetFPS }) 等 — 完整见 GameRoomExample
  }

  onLeave(client: Client, consented: boolean) {
    // 从 state.players 移除或标记离线、重连窗口等 — 见 GameRoomExample
  }

  onDispose() {
    this.stopFrameSync(); // 与 startFrameSync 成对,避免房间销毁后定时器仍跑
  }

  /**
   * 每一逻辑帧的**主循环**:消费本帧已送达的客户端输入,更新 Schema、广播自定义消息。
   * - `inputs`:本 tick 内 `handleClientInput` 收集到的列表(每个 `clientId` 通常保留**最新一条**后于 tick 开头清空 pending)。
   * - 每项为 `ClientInput`:`clientId`(与 `client.sessionId` 一致)、`inputs`(客户端 `room.send("input", { inputs })` 里的对象)、`frame`(客户端可选指定,默认服务端当前帧)、`timestamp`。
   */
  protected onFrameUpdate(frame: number, inputs: ClientInput[]) {
    for (const input of inputs) {
      const { clientId, inputs: payload } = input;
      // 在此读取 payload.move / attack / jump,写 this.state.players[clientId]、this.broadcast("playerAction", …)
      // 默认语义与表格见上文「下行:移动、攻击…」;完整实现见 GameRoomExample.processPlayerInput
      void clientId;
      void payload;
    }
    // 将当前逻辑帧写入 Schema,便于客户端 UI / 调试与 GameRoomExample 一致
    this.state.frame = frame;
  }

  /**
   * **同一 `frame`、在 `onFrameUpdate` 之后**调用;用于本帧收尾或与「整帧快照」相关的逻辑。
   * - `frameData`:`{ frame, timestamp, inputs }`,其中 `inputs` 与传入 `onFrameUpdate` 的数组为**同一批**(同一引用);可选字段 `state` 由业务自行约定,框架不会自动填充。
   * - `recordFrames === true` 时,管理器在调用本方法**之前**已把等价的 `FrameData` 推入内部列表(长度有上限),便于回放;纯状态玩法可在此留空。
   */
  protected onFrameSync(frame: number, frameData: FrameData) {
    // 恒有 frameData.frame === frame;frameData.inputs 与 onFrameUpdate 的 `inputs` 为同一批引用。
    // 典型用途:在开启 recordFrames 时与内存中的帧历史对齐;或 this.broadcast("lockstepFrame", { frame, inputs: frameData.inputs }) 等自定义全帧同步(非 Colyseus 默认)。
    void frame;
    void frameData;
  }
}

客户端(可运行)

  1. 内置演示页(完整 UI + 按键发 input
    仓库 src/public/FrameSync.html:连接服务器、joinOrCreate、监听 frameSync / playerAction / state、通过虚拟方向键 room.send("input", { inputs })。启动服务后浏览器打开 http://localhost:2567/FrameSync.html,房间名选 game_room,连接选项 JSON 须含有效 token(与 @RequireAuth() 一致),例如:{"fps":20,"token":"<你的 JWT>"}

  2. 独立脚本(最小闭环)
    安装对应 SDK 后,下列 Tab 与上表 协议一致joinOrCreate("game_room", { token, fps })、监听 frameSync / playerAction、定时上行 input(按项目改为每帧或与渲染循环对齐)。C# 为 Unity MonoBehaviour 示例(依赖见 Unity 接入);GDScript 为 Godot 4 + Colyseus 插件(join_or_create / send_messageGodot 接入)。

ts
import Colyseus from "colyseus.js";

const ENDPOINT = "ws://127.0.0.1:2567";
const TOKEN = "YOUR_JWT_ACCESS_TOKEN";

async function main() {
  const client = new Colyseus.Client(ENDPOINT);
  const room = await client.joinOrCreate("game_room", { token: TOKEN, fps: 20 });

  room.onMessage("frameSync", (p: { currentFrame: number; targetFPS: number }) => {
    console.log("frameSync", p.currentFrame, p.targetFPS);
  });

  room.onMessage("playerAction", (msg: { sessionId: string; action: string; data: unknown }) => {
    console.log("playerAction", msg.action, msg.sessionId, msg.data);
  });

  room.state.players.onAdd((player, sessionId) => {
    player.onChange(() => console.log("state", sessionId, player.x, player.y, player.attacking));
  });

  const interval = setInterval(() => {
    room.send("input", {
      inputs: { move: { x: 0, y: 0 }, attack: false, jump: false },
    });
  }, 50);

  room.onLeave(() => clearInterval(interval));
}

main().catch(console.error);
csharp
using System;
using System.Collections.Generic;
using Colyseus;
using Colyseus.Schema;
using UnityEngine;

/// <summary>挂到任意 GameObject;将 accessToken 换为有效 JWT(与 @RequireAuth 一致)。</summary>
public class FrameSyncGameRoomMinimalDemo : MonoBehaviour
{
    [SerializeField] string wsHost = "127.0.0.1";
    [SerializeField] int wsPort = 2567;
    [SerializeField] string accessToken = "YOUR_JWT_ACCESS_TOKEN";

    Colyseus.Client _client;
    Room<DynamicSchema> _room;

    async void Start()
    {
        _client = new Colyseus.Client($"ws://{wsHost}:{wsPort}");
        var options = new Dictionary<string, object> { { "token", accessToken }, { "fps", 20 } };
        try
        {
            _room = await _client.JoinOrCreate<DynamicSchema>("game_room", options);
        }
        catch (Exception e)
        {
            Debug.LogError("进房失败: " + e.Message);
            return;
        }

        _room.OnMessage<Dictionary<string, object>>("frameSync", p =>
        {
            p.TryGetValue("currentFrame", out var cf);
            p.TryGetValue("targetFPS", out var tf);
            Debug.Log($"frameSync currentFrame={cf} targetFPS={tf}");
        });

        _room.OnMessage<Dictionary<string, object>>("playerAction", msg =>
        {
            msg.TryGetValue("action", out var act);
            msg.TryGetValue("sessionId", out var sid);
            Debug.Log($"playerAction {act} sessionId={sid}");
        });

        // 与 TS 的 state.players 对应:正式项目建议 Schema Codegen 后监听 Player 字段
        InvokeRepeating(nameof(SendTickInput), 0f, 0.05f);
        _room.OnLeave += _ => CancelInvoke(nameof(SendTickInput));
    }

    void SendTickInput()
    {
        if (_room == null) return;
        _room.Send("input", new Dictionary<string, object>
        {
            {
                "inputs", new Dictionary<string, object>
                {
                    { "move", new Dictionary<string, object> { { "x", 0 }, { "y", 0 } } },
                    { "attack", false },
                    { "jump", false },
                }
            },
        });
    }

    async void OnDestroy()
    {
        CancelInvoke(nameof(SendTickInput));
        if (_room != null)
            await _room.Leave();
    }
}
gdscript
extends Node

## 将 TOKEN 换为有效 JWT。API 以当前 Colyseus Godot 插件为准(与 Godot 接入页一致)。
const WS_URL := "ws://127.0.0.1:2567"
const TOKEN := "YOUR_JWT_ACCESS_TOKEN"

var _client: Colyseus.Client
var _room: Colyseus.Room
var _input_timer: Timer

func _ready() -> void:
    _client = Colyseus.Client.new(WS_URL)
    _room = _client.join_or_create("game_room", {"token": TOKEN, "fps": 20})
    if not _room:
        push_error("join_or_create 失败")
        return
    _room.joined.connect(_on_joined)
    _room.message_received.connect(_on_message_received)
    _room.error.connect(_on_room_error)

func _on_joined() -> void:
    _input_timer = Timer.new()
    _input_timer.wait_time = 0.05
    _input_timer.timeout.connect(_send_tick_input)
    add_child(_input_timer)
    _input_timer.start()

func _send_tick_input() -> void:
    if _room and _room.connected:
        _room.send_message("input", {
            "inputs": {
                "move": {"x": 0, "y": 0},
                "attack": false,
                "jump": false,
            },
        })

func _on_message_received(type: Variant, data: Variant) -> void:
    var t := str(type)
    if t == "frameSync":
        print("frameSync ", data)
    elif t == "playerAction":
        print("playerAction ", data)

func _on_room_error(code: int, message: String) -> void:
    push_error("房间错误 %d: %s" % [code, message])

func _exit_tree() -> void:
    if _input_timer:
        _input_timer.queue_free()
    if _room and _room.connected:
        _room.leave()

更多进房方式(match:foundjoinById)及 joinById 完整片段见下一节 客户端示例代码

客户端示例代码

典型来源:由 匹配与开房 收到 match:foundjoinById 进入本房间,并将 joinOptionstoken 合并;本地调试也可在服务端允许时 joinOrCreate("game_room", { token, ... })(字段以 RequireAuth 与房间逻辑为准)。

下面同一段示例里出现了 send("input")onMessage("playerAction")state.players 三处,容易误以为「下行是下行的 echo」。请分开理解:

片段方向含义
send("input", { inputs: … })客户端 → 服务端本帧操作意图;不会被服务端原样当作 playerAction 发回。
onMessage("playerAction")服务端 → 客户端事件通知;action === "move"data{ x, y, move }(权威坐标 + 本帧位移向量),不是整份上行 inputs
state.players / onChange服务端 → 客户端(Schema)Player 上持续同步的字段(如 x/y/attacking);与 playerAction 并行,用途不同(状态 vs 事件)。

(客户端代码)

ts
import Colyseus from "colyseus.js";

const ENDPOINT = "ws://127.0.0.1:2567";
const TOKEN = "YOUR_JWT_ACCESS_TOKEN";

/** 下行:move 时 data 与 GameRoomExample 广播一致(非上行 inputs 原样) */
type PlayerActionMoveData = {
  x: number;
  y: number;
  move: { x?: number; y?: number };
};

/** 已由匹配拿到 found 时 */
export async function enterGameRoomFromMatch(
  client: Colyseus.Client,
  roomId: string,
  joinOptions: Record<string, unknown>
) {
  const game = await client.joinById(roomId, {
    ...joinOptions,
    token: TOKEN,
  });

  game.onMessage("frameSync", (p: { currentFrame: number; targetFPS: number }) => {
    console.log("frameSync", p.currentFrame, p.targetFPS);
  });

  game.onMessage("playerOffline", (p: unknown) => {
    console.log("playerOffline", p);
  });

  game.onMessage("reconnect:ok", (p: unknown) => {
    console.log("reconnect:ok", p);
  });

  // 下行:其它玩家的移动/攻击/跳跃(payload 由当前 game_room 绑定的房间类组装;默认见 GameRoomExample)
  game.onMessage(
    "playerAction",
    (p: { sessionId: string; action: string; data: Record<string, unknown> }) => {
      if (p.action === "move") {
        const d = p.data as PlayerActionMoveData;
        console.log("player moved", p.sessionId, d.x, d.y, "vec", d.move);
      }
      // attack / jump:见上文表格中的 data 形状
    }
  );

  // 下行:Schema 权威状态(与 playerAction 可同时使用)
  game.state.players.onAdd((player, sessionId) => {
    player.onChange(() => {
      console.log("state player", sessionId, player.x, player.y, player.attacking);
    });
  });

  // 上行:本帧输入(仅发往服务端;默认示例 GameRoomExample 解析 move/attack/jump)
  game.send("input", {
    inputs: { move: { x: 0, y: 1 }, attack: false, jump: false },
  });

  // 可选:房内文字
  game.send("chat", "hello");

  return game;
}
csharp
using System.Collections.Generic;
using Colyseus;
using Colyseus.Schema;

public static async Task<Room<DynamicSchema>> EnterGameRoomFromMatch(
    Colyseus.Client client,
    string roomId,
    Dictionary<string, object> joinOptions,
    string accessToken)
{
    var opts = new Dictionary<string, object>(joinOptions) { { "token", accessToken } };
    var game = await client.JoinById<DynamicSchema>(roomId, opts);

    game.OnMessage<Dictionary<string, object>>("frameSync", p =>
    {
        p.TryGetValue("currentFrame", out var cf);
        UnityEngine.Debug.Log("frameSync currentFrame=" + cf);
    });

    game.OnMessage<object>("playerOffline", p => UnityEngine.Debug.Log("playerOffline"));

    game.OnMessage<object>("reconnect:ok", _ => UnityEngine.Debug.Log("reconnect:ok"));

    game.OnMessage<Dictionary<string, object>>("playerAction", p =>
    {
        if (p.TryGetValue("action", out var act) && act?.ToString() == "move" && p.TryGetValue("data", out var d)
            && d is Dictionary<string, object> data)
        {
            data.TryGetValue("x", out var x);
            data.TryGetValue("y", out var y);
            UnityEngine.Debug.Log($"playerAction move x={x} y={y}");
        }
    });

    // 权威状态:使用 Schema Codegen 时可监听 Player 字段变化;此处 DynamicSchema 仅作示意,正式项目建议生成状态类型

    game.Send("input", new Dictionary<string, object> {
        { "inputs", new Dictionary<string, object> {
            { "move", new Dictionary<string, object> { { "x", 0 }, { "y", 1 } } },
            { "attack", false },
            { "jump", false },
        }},
    });

    game.Send("chat", "hello");

    return game;
}
gdscript
# 进入 game_room 后监听帧同步并发送 input(API 以插件为准)
func _on_game_joined(game: Colyseus.Room) -> void:
    game.message_received.connect(_on_game_message.bind(game))

func _on_game_message(game: Colyseus.Room, type: Variant, data: Variant) -> void:
    var t := str(type)
    if t == "frameSync":
        print("frameSync ", data)
    elif t == "playerOffline":
        print("playerOffline ", data)
    elif t == "reconnect:ok":
        print("reconnect:ok ", data)
    elif t == "playerAction" and typeof(data) == TYPE_DICTIONARY:
        var d: Dictionary = data
        var action := str(d.get("action", ""))
        if action == "move":
            var inner: Variant = d.get("data", {})
            if typeof(inner) == TYPE_DICTIONARY:
                print("playerAction move ", (inner as Dictionary).get("x"), (inner as Dictionary).get("y"))
        # attack / jump 同理

func send_frame_input(game: Colyseus.Room) -> void:
    game.send_message("input", {
        "inputs": {
            "move": {"x": 0, "y": 1},
            "attack": false,
            "jump": false,
        },
    })

func send_chat(game: Colyseus.Room, text: String) -> void:
    game.send_message("chat", text)

重连时请在 MATCH_RECONNECT_WINDOW_MS 窗口内,使用与原局一致的 matchIdreconnectKeyuserId 会话 再次 joinById(options 与 加入房间 表一致),成功可收到 reconnect:ok

服务端 → 客户端消息(常见)

消息说明
frameSync当前帧号与目标 FPS(加入时)。
playerJoined新玩家加入,sessionIdplayerCount
playerLeft玩家离开(或超时移除后),含 playerCount
playerOnline / playerOffline重连恢复或异常断线进入重连窗口时。
playerAction默认 GameRoomExamplemove 在坐标变时、jump 在当帧为真时、attack 在当帧 inputsattack 键时广播;payload 含 sessionIdactiondata(见上文)。若已换绑房间类,以其实现为准。
reconnect:ok重连或同 userId 去重成功。

断线重连

  • 非自愿离开(consented === false)时,玩家会被标记为离线,并进入 MATCH_RECONNECT_WINDOW_MS 毫秒的重连窗口(默认 15000,可通过环境变量覆盖)。
  • 窗口内使用 同一 userId + 匹配下发的 reconnectKey(及一致的 matchId 再次加入,可迁移会话并收到 reconnect:ok
  • 重连密钥会尝试写入 Redis(mm:reconnect:{matchId}:{userId}),便于多实例扩展;Redis 不可用时仍可使用进程内 pendingReconnect

FrameSync 基类要点

src/utils/FrameSync.ts 中的 FrameSyncManager

  • 使用 setIntervaltargetFPS 驱动 tick
  • maxFrameDelay 用于告警帧耗时过长(默认 100ms)。
  • recordFrames 为 true 时会保留近期帧数据(有长度上限,防止内存无限增长)。

子类实现约定:须实现 onFrameUpdate(每帧权威逻辑)与 onFrameSync(同帧收尾;可与 recordFrames 快照、FrameData 整帧广播一起思考)。二者在 tick先后调用、帧号相同。内置 GameRoomExample:在 onFrameUpdate 中调用 processPlayerInput 并写入 state.frameonFrameSync 内是否还有其它写入以仓库 GameRoomExample.ts 为准。

延伸阅读