帧同步与游戏房
客户端进房名 game_room。其中 应作为「稳定契约」来理解 的是 FrameSyncRoom + input 消息 + 固定帧 tick + Schema 同步——与具体派生类名无关。
默认实现是内置示例类 GameRoomExample(src/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.ts 中 game_room 默认传入:
fps:目标帧率,默认 20。recordFrames:是否记录帧数据(回放用),默认 false。
匹配创建房间时还会传入 matchId、playersPerMatch、queueKey 或 partyId 等;其中 playersPerMatch 会写入 maxClients,以约束一局人数上限。
加入房间(onJoin)
除 JWT 外,推荐携带匹配下发的字段(与 match:found.joinOptions 对齐):
| 字段 | 说明 |
|---|---|
matchId | 对局 ID,用于重连校验与 Redis 重连键命名空间。 |
seatIndex | 座位号。 |
reconnectKey | 与断线时服务端生成的密钥一致时,可恢复同一逻辑玩家。 |
demoUserId | 可选;用于同一 JWT 多开测试时区分不同玩家 ID。 |
加入成功后,服务端可能下发:
frameSync:currentFrame、targetFPS。reconnect:ok:重连或去重成功后。- 其它客户端会收到
playerJoined等(见下文)。
客户端 → 服务端消息
input(帧输入)
由 FrameSyncRoom 注册,payload 建议包含:
inputs:任意结构,由游戏逻辑解释(示例中支持move、attack、jump)。frame:可选,指定输入所属帧;不传则使用服务端当前帧。
服务端每帧合并各 clientId 的最新一条待处理输入,在 onFrameUpdate 中交给子类实现的输入处理(默认示例中为 GameRoomExample 内的 processPlayerInput)。
下行:移动、攻击等结果从哪来(与 input 的关系)
input 是客户端 → 服务端的「操作意图」,服务端在 onFrameUpdate 里消费后,不会再以同名消息把您的 input 原样广播给所有人。您也不要在客户端写 onMessage("input", …) 来等自己的摇杆数据——那一路是上行。
内置示例 GameRoomExample 在 processPlayerInput 中做了两件事,供各端表现其它玩家与权威状态(您二开后的房间可另定规则):
- 更新 Schema(
MyRoomState/Player):例如player.x、player.y、player.attacking会随逻辑帧变化。所有在房内的客户端都会通过 Colyseus 状态同步 收到增量。适合:血条、位置插值、与 UI 绑定的数据。字段见 状态同步(Schema)。 - 广播
playerAction:move仅在坐标实际改变时广播;jump在本帧inputs.jump === true时广播;attack则在本帧inputs带有attack键(!== undefined)时即更新并广播,未做边沿检测——若客户端每帧都发attack: true/false,可能每帧都收到一条playerAction。便于特效、音效、非 Schema 的轻量表现。当前示例 payload 形态如下(与src/rooms/GameRoomExample.ts一致;若您已替换game_room绑定的类,以您仓库为准):
action | 说明 | data 示例 |
|---|---|---|
move | 因 inputs.move 导致 x/y 变化 | { x, y, move }(move 为本次输入向量) |
attack | 当帧 inputs 含 attack 键(非 undefined) | { attacking: boolean } |
jump | inputs.jump === true 的当帧 | { jump: true } |
共同字段:sessionId(与 Colyseus 的 client.sessionId 一致,用于区分操作者)。
推荐做法:以 Schema 为权威位置 驱动角色根节点;playerAction 作补间、打击感、预测纠偏的触发。若您完全锁步且只信消息,也可只消费 playerAction(需自行保证与状态不冲突)。
完整示例:服务端与客户端
下列组合即本仓库中的「端到端」帧同步演示:服务端以源文件为准;客户端可用内置页面或自写脚本,二者协议一致(input + Schema + playerAction 等)。
服务端(事实源)
| 路径 | 职责 |
|---|---|
src/utils/FrameSync.ts | FrameSyncRoom:注册 onMessage("input")、initFrameSync / startFrameSync、FrameSyncManager 按 targetFPS 调用 onFrameUpdate |
src/rooms/schema/MyRoomState.ts | MyRoomState、Player Schema |
src/rooms/GameRoomExample.ts | 完整对局示例:onFrameUpdate → processPlayerInput、重连、playerAction、chat |
src/app.config.ts | gameServer.define("game_room", GameRoomExample, { fps: 20, recordFrames: false }) |
继承 FrameSyncRoom 的子类至少需要:onCreate 里 initFrameSync + startFrameSync,onDispose 里 stopFrameSync,并实现 onFrameUpdate / onFrameSync;进房鉴权、玩家写入 state.players 等见 GameRoomExample。
谁在调用 onFrameUpdate / onFrameSync? 不是 Colyseus 自带的 Room 基类,而是本仓库 FrameSyncManager:在 startFrameSync() 里用 setInterval 按 targetFPS 周期执行其私有方法 tick(),tick 末尾依次调用管理器上的两个回调。FrameSyncRoom.initFrameSync() 会把这两个回调重写成转发到当前房间实例——即执行你在子类里重写的 protected onFrameUpdate、protected onFrameSync。因此无需、也不应在子类里手写 super.onFrameUpdate(...) 来触发帧循环;只要 initFrameSync + startFrameSync 已调用,由定时器驱动即可。
每帧(FrameSyncManager.tick)顺序(以 FrameSync.ts 为准):从 pendingInputs 取出本 tick 内各客户端已合并的输入 → 组装 FrameData(frame、timestamp、inputs)→ 若 recordFrames === true 则把该快照追加到内存环形缓冲(用于回放)→ 调用 onFrameUpdate(frame, inputs) → 调用 onFrameSync(frame, frameData) → currentFrame++。两回调收到的 frame 相同;玩法与权威状态应写在 onFrameUpdate;onFrameSync 适合同一帧上的补充同步、调试字段或与 recordFrames 快照对齐的逻辑。
下面为最小骨架(省略与帧无关的重连、聊天等,完整逻辑仍以 GameRoomExample 为准):
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;
}
}客户端(可运行)
内置演示页(完整 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>"}。独立脚本(最小闭环)
安装对应 SDK 后,下列 Tab 与上表 协议一致:joinOrCreate("game_room", { token, fps })、监听frameSync/playerAction、定时上行input(按项目改为每帧或与渲染循环对齐)。C# 为 Unity MonoBehaviour 示例(依赖见 Unity 接入);GDScript 为 Godot 4 + Colyseus 插件(join_or_create/send_message见 Godot 接入)。
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);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();
}
}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:found 后 joinById)及 joinById 完整片段见下一节 客户端示例代码。
客户端示例代码
典型来源:由 匹配与开房 收到 match:found 后 joinById 进入本房间,并将 joinOptions 与 token 合并;本地调试也可在服务端允许时 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 事件)。 |
(客户端代码)
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;
}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;
}# 进入 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 窗口内,使用与原局一致的 matchId、reconnectKey、userId 会话 再次 joinById(options 与 加入房间 表一致),成功可收到 reconnect:ok。
服务端 → 客户端消息(常见)
| 消息 | 说明 |
|---|---|
frameSync | 当前帧号与目标 FPS(加入时)。 |
playerJoined | 新玩家加入,sessionId、playerCount。 |
playerLeft | 玩家离开(或超时移除后),含 playerCount。 |
playerOnline / playerOffline | 重连恢复或异常断线进入重连窗口时。 |
playerAction | 默认 GameRoomExample:move 在坐标变时、jump 在当帧为真时、attack 在当帧 inputs 含 attack 键时广播;payload 含 sessionId、action、data(见上文)。若已换绑房间类,以其实现为准。 |
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:
- 使用
setInterval按targetFPS驱动tick。 maxFrameDelay用于告警帧耗时过长(默认 100ms)。recordFrames为 true 时会保留近期帧数据(有长度上限,防止内存无限增长)。
子类实现约定:须实现 onFrameUpdate(每帧权威逻辑)与 onFrameSync(同帧收尾;可与 recordFrames 快照、FrameData 整帧广播一起思考)。二者在 tick 内先后调用、帧号相同。内置 GameRoomExample:在 onFrameUpdate 中调用 processPlayerInput 并写入 state.frame;onFrameSync 内是否还有其它写入以仓库 GameRoomExample.ts 为准。
延伸阅读
- 状态同步(Schema)(
MyRoomState、Player字段) - 消息协议总表