Web 客户端接入
im.game 让已登录的玩家加入你的服务端创建的游戏房间,和其他玩家实时收发操作。适用于网页游戏和 Cocos Creator 的 Web 平台。房间的规则和开通条件见游戏房间概述。
SDK 版本
游戏房间需要 @deeprespond/im-web 1.1.0 及以上;React、Vue 的 hooks 需要 @deeprespond/im-web-react、@deeprespond/im-web-vue 1.1.1 及以上(1.1.0 的 React useGameRoom() 在加入房间后会报错)。
const room = await im.game.join('rank-8812', { ticket: ticketFromBusinessServer });
room.onFrame((f) => game.step(f.frame, f.inputs)); // 严格按帧号顺序,缺帧自动补齐后再回调
room.onMember((e) => ui.updateSeat(e.seat, e.type)); // online / offline / left
room.onClose((e) => ui.showResult(e.reason, e.result)); // 对局结束、被移出等,之后不再重连
room.ready(); // all_ready 房间:所有人准备后开局
const timer = setInterval(() => room.sendInput(game.localInput()), 33);
// 玩家退出对局时
clearInterval(timer);
await room.leave();| 要做什么 | 看哪里 |
|---|---|
| 加入房间 | 加入 |
| 每帧发送操作、按帧推进游戏 | 帧同步 |
| 发聊天、表情、回合制的出牌 | 自定义消息 |
| 显示座位、谁在线、延迟 | 房间状态与成员 |
| 处理断线、对局结束 | 断线与结束 |
| 刷新页面后回到对局 | 回到对局 |
| 加入失败时提示玩家 | 错误处理 |
| 在 React、Vue 中使用 | React 和 Vue |
你负责 / SDK 负责
| 你负责 | SDK 负责 |
|---|---|
| 从你的服务端拿到加入票据 | 取连接信息、连接游戏节点、握手 |
每帧调用 sendInput 发送本地操作 | 按帧号排序、去掉重复的帧、缺帧时向节点补帧 |
在 onFrame 里按帧推进游戏逻辑 | 每秒心跳、测延迟、估算服务器时间 |
根据 onClose 显示结算或提示 | 断线后自动重连,必要时重新取连接信息,超过重连窗口后结束 |
加入
if (!im.game.supported) throw new Error('当前浏览器不支持');
const room = await im.game.join('rank-8812', { ticket: ticketFromBusinessServer });
console.log(room.state, room.seat, room.members); // 'connected'、本人座位、在座的成员| 选项 | 说明 |
|---|---|
ticket | 你的服务端签发给这个玩家的票据。open 房间不传 |
client_session_id | 这次加入的 ID(8–64 位可见 ASCII)。不传时 SDK 生成;同一次加入内重新取连接信息时保持不变 |
signal | AbortSignal,取消还没完成的加入 |
- 连上游戏节点、完成握手后才返回,一般在 1 秒内。
- 同一个客户端同一时刻只能在一个房间里;
im.game.current是当前的房间。对同一个房间再次调用join返回同一个对象,加入另一个房间以invalid_state拒绝,先leave()。 - 浏览器使用 WebSocket 连接游戏节点,页面需要能访问节点的
wss://地址。
帧同步
sync_mode 为 frame 的房间(默认),游戏节点每秒推进 tick_rate 帧。每一帧,节点把各座位这一帧的操作打包发给所有人;所有玩家按同样的顺序处理同样的输入,画面就保持一致。
room.onRoomState((e) => {
if (e.state === 'started') console.log('开局,第一帧为', e.startFrame);
});
room.onFrame((f) => {
// f.inputs 只包含这一帧有操作的座位
game.step(f.frame, f.inputs);
});
// 每帧发送一次本地操作(不超过 128 字节)
setInterval(() => room.sendInput(game.localInput()), 1000 / 30);onFrame严格按帧号从小到大回调,不重复、不跳号。丢包时 SDK 先用后续包里的冗余补上,补不上时向节点请求,补齐后再按顺序回调。sendInput在同一轮事件循环里多次调用只发最后一次。没连上时返回false,不排队——过期的操作没有意义。- 开局之前发送的操作会被忽略。
room.frame是已经回调的最后一帧。 - 操作用你自己的二进制格式编码,例如方向和按键各占几位。
快照:建房时开启 snapshot 的房间,节点会指定一名玩家定期提供游戏状态。room.getSnapshot().snapshotProvider 为 true 的玩家按间隔调用 putSnapshot;有人断线太久、缺的帧太多时,会先收到 onSnapshot,再从快照之后的帧继续:
setInterval(() => {
if (room.getSnapshot().snapshotProvider) room.putSnapshot(game.frame, game.serialize()); // 不超过 64KB
}, 30_000);
room.onSnapshot((s) => game.applySnapshot(s.data)); // 之后的 onFrame 从 s.frame + 1 开始自定义消息
relay 房间没有帧,玩家之间只收发自定义消息;frame 房间也可以用它发聊天、表情这类不进帧的数据。
const text = new TextEncoder();
room.sendCustom(text.encode('gg')); // 发给除自己以外的所有人
room.sendCustom(text.encode('出牌:红桃A'), { to: [1], reliable: true }); // 只发给座位 1
room.onCustom((m) => console.log(`座位 ${m.from}:`, new TextDecoder().decode(m.data)));单条不超过 1KB;每个座位每秒最多 60 条、32KB,超过的被节点丢弃,连续 10 秒超限会被移出房间。
房间状态与成员
| 属性 | 说明 |
|---|---|
state | 连接状态:connecting、connected、reconnecting(恢复中)、rejoining(重新取连接信息中)、closed、left |
roomState | waiting 等待开局、started 对局中、closing 正在结束 |
seat | 本人座位(0–9) |
members | 在座的成员 { seat, username, online },离开的不在其中 |
frame | 已回调的最后一帧 |
rtt | 最近 10 次往返延迟的中位数(毫秒),刚连上时为 null |
serverTime() | 估算的服务器时间(毫秒),用于各端对齐倒计时 |
room.onMember((e) => {
// e.type:online 上线、offline 断线(可能回来)、left 离开
ui.updateSeat(e.seat, e.type);
});
room.onState((s) => ui.showReconnecting(s === 'reconnecting' || s === 'rejoining'));room.getSnapshot() 返回包含以上信息的只读对象,内容变化时换成新对象;room.subscribe(fn) 在变化时通知。帧号不在快照里(每帧都变),帧用 onFrame 接收。
所有 onXxx 方法和 room.on(event, fn) 都返回取消订阅的函数。
断线与结束
断线时你不需要写代码:SDK 在 5 秒没收到数据或连接断开时立即重连,之后每隔 1、2、4、4… 秒再试,回到原来的座位,缺的帧按顺序补齐。重连期间 state 为 reconnecting 或 rejoining,sendInput 返回 false。
以下情况房间对这个玩家结束,onClose 回调一次,SDK 不再重连:
reason | 什么时候 | 建议的界面 |
|---|---|---|
room_closed | 对局结束。closeReason 为结束原因(如 closed_by_server、expired),result 为你的服务端给的结算结果 | 显示结算 |
left | 本人调用了 leave() | — |
timeout | 断线超过房间的重连窗口 | 「网络中断,已离开对局」 |
kicked | 被你的服务端移出 | 「你已被移出房间」 |
replaced | 同一账号在别处加入了这个座位 | 「你已在其他设备进入对局」 |
node_draining、room_not_found | 游戏服务器维护、房间已不在 | 「对局已中断」 |
signed_out、destroyed | 退出登录、销毁了客户端(不通知其他玩家,座位按重连窗口保留) | — |
room.onRoomState((e) => {
if (e.state === 'closing') console.log('即将结束', e.reason, e.result);
});
room.onClose((e) => {
if (e.reason === 'room_closed') ui.showResult(e.closeReason ?? '', e.result);
else ui.toast(`离开对局:${e.reason}`);
});离开:await room.leave() 通知游戏节点和 IM,其他玩家收到这个座位 left。可以重复调用。
回到对局
刷新页面或重新打开后,连接信息只在内存里,需要重新加入:
const { room: current, member } = await im.game.getCurrent();
if (current && member) {
// 还在对局中的座位:不需要新票据,重新加入回到原座位
const room = await im.game.join(current.room_id);
ui.toast(`回到座位 ${room.seat}`);
}getCurrent 只返回本设备(本次登录)还没离开的房间。超过重连窗口的座位已经释放,需要你的服务端重新签发票据。
错误处理
join 失败时抛出 DRError。用 im.game.errorKind(err) 得到分类,决定给玩家看什么:
try {
await im.game.join('rank-8812', { ticket: ticketFromBusinessServer });
} catch (err) {
switch (im.game.errorKind(err)) {
case 'ticket_invalid': ui.toast('入场凭证已失效,请重新匹配'); break;
case 'room_full': ui.toast('房间已满'); break;
case 'joined_on_other_device': ui.toast('你已在其他设备进入该对局'); break;
case 'room_closed': ui.toast('对局已结束'); break;
default: ui.toast('暂时无法进入对局,请稍后再试');
}
}| 分类 | 原因 | 建议 |
|---|---|---|
ticket_invalid | 票据不对、过期或已经用过 | 让你的服务端重新签发 |
game_disabled | 平台没有开放游戏房间,或套餐不含 | 「暂时无法进入对局」 |
room_closed | 房间不存在或已经结束 | 「对局已结束」 |
room_full | 座位已满,或指定的座位已被占用 | 「房间已满」 |
joined_on_other_device | 同一账号已在其他设备加入这个房间 | 提示玩家,或让他在原设备离开 |
node_unavailable | 房间所在的游戏服务器暂时不可用 | 稍后重试 |
upgrade_required | SDK 版本太旧 | 升级 SDK |
network | 网络错误或超时 | 稍后重试 |
原始的错误码在 err.code、err.reason:加入接口的错误同服务端错误;游戏节点拒绝连接时 code 为 game_node_error。
本地检查:sendInput 超过 128 字节、sendCustom 超过 1KB、putSnapshot 超过 64KB 时抛出 local_validation。
React 和 Vue
useGameRoom() 返回当前房间的快照(加入、结束时自动更新),useGameEvent() 订阅房间的事件、组件卸载时自动取消。加入房间仍然由你在用户操作时调用 im.game.join()。
import { useDRClient, useGameEvent, useGameRoom } from '@deeprespond/im-web-react';
function Match({ ticket }: { ticket: string }) {
const im = useDRClient();
const room = useGameRoom(); // null:还没加入
useGameEvent(undefined, 'frame', (f) => game.step(f.frame, f.inputs));
if (!room) return <button onClick={() => im?.game.join('rank-8812', { ticket })}>进入对局</button>;
return <div>座位 {room.seat} · {room.state} · 延迟 {room.rtt ?? '-'} ms · 在座 {room.members.length} 人</div>;
}Vue 的用法相同,返回只读的 ShallowRef:
import { useGameEvent, useGameRoom } from '@deeprespond/im-web-vue';
const room = useGameRoom(); // room.value?.state
useGameEvent(undefined, 'member', (e) => ui.updateSeat(e.seat, e.type));| API | 说明 |
|---|---|
useGameRoom(room?) | 不传时跟随 im.game.current;传入房间对象时用它;传 null 返回 null |
useGameEvent(room, event, listener) | room 为 undefined 时用当前房间;event 为 frame、custom、member、roomState、snapshot、state、close |
其他 hooks 见在 React 和 Vue 中使用。
接口参考
im.game | 说明 |
|---|---|
supported | 当前环境能否加入游戏房间 |
current | 当前的房间,没有时为 null |
join(roomId, options?) | 加入,返回 GameRoom |
getCurrent() | 本设备当前所在的房间 { room, member } |
leave(roomId) | 不通过房间对象离开 |
errorKind(err) | 错误的分类 |
GameRoom 方法 | 说明 |
|---|---|
sendInput(data) | 本帧操作,≤ 128 字节 |
sendCustom(data, { to?, reliable? }) | 自定义消息,≤ 1KB;to 为座位数组 |
ready() | 准备 |
start() | 房主开始。通过 IM 创建的房间暂不开放房主开始,manual 房间由你的服务端调用开局接口 |
putSnapshot(frame, data) | 提供快照,≤ 64KB |
leave() | 离开 |
getSnapshot()、subscribe(fn) | 状态快照和变化通知 |
on(event, fn)、onState、onRoomState、onFrame、onCustom、onMember、onSnapshot、onClose | 事件,返回取消函数 |
页面切到后台时,浏览器可能降低计时器频率,帧和心跳会变慢;长时间在后台可能断线,回到前台后 SDK 自动重连。
