介绍
@p2play-js/p2p-game 是一个模块化的TypeScript库,用于构建基于浏览器的P2P(WebRTC)多人游戏。它提供 状态同步(完整/增量)、一致性策略(时间戳/权威)、最小WebSocket信令适配器、移动助手、 主机选举/迁移和ping覆盖层。
快速开始
npm install @p2play-js/p2p-game
import { P2PGameLibrary, WebSocketSignaling } from "@p2play-js/p2p-game";
const signaling = new WebSocketSignaling("玩家A", "房间-42", "wss://你的-ws.示例");
const multiP2PGame = new P2PGameLibrary({
signaling,
maxPlayers: 4,
syncStrategy: "delta",
conflictResolution: "timestamp",
});
await multiP2PGame.start();
multiP2PGame.on("playerMove", (id, pos) => {/* 渲染 */});
演示
-
完整小游戏:examples/complete
- 基础信令测试器:examples/basic
架构
- 该库使用 WebSocket 信令服务器来管理房间、维护玩家标识列表,并将 SDP/ICE 消息路由到特定对端。
- 对端形成全互联网格:对于每一对对端,按字典序更小的
playerId
负责创建 WebRTC offer,从而避免同时发起导致的冲突。 - 当 DataChannel 建立后,游戏消息改为点对点传输;信令服务器不再转发应用层流量。
- 主机选举是确定性的:最小的
playerId
成为主机;主机离开时,由次小者继任并发送全量快照。
信令时序
全互联拓扑
状态同步
- 全量快照:加入/迁移、纠偏重同步。
- 增量更新:针对路径的局部变更(实践中多采用混合策略)。
一致性
- 时间戳(默认):按发送方序列采用“最后写入胜”(LWW)。
- 权威:仅接受权威端(主机或固定ID)的操作。
seq
小于最近已见序号的消息将被忽略。在权威模式下,由一个对端(常为可信主机)应用所有动作以避免冲突与作弊;其他对端发送意图并接受纠正。
移动
本库旨在网络抖动下实现平滑且可预期的移动。它结合了插值(在已知采样间平滑过渡)与 限幅外推(短预测窗口),以在不偏离真实状态过多的前提下掩盖迟到更新。
插值
收到远端新位置时,我们不会瞬移至该位置;而是每帧推进剩余距离的一小部分。
smoothing
系数控制该比例(0..1)。值越大视觉延迟越小,但可能显得“漂浮”。
// 伪代码
const clampedVx = clamp(velocity.x, -maxSpeed, maxSpeed);
const clampedVy = clamp(velocity.y, -maxSpeed, maxSpeed);
// allowedDtSec 考虑外推上限(见下)
position.x += clampedVx * allowedDtSec * smoothing;
position.y += clampedVy * allowedDtSec * smoothing;
// 可选 Z 轴(如提供)
0.2–0.3
开始。若移动明显落后输入,可增大;若出现振荡或过冲,则减小。
外推(带上限)
若本帧没有收到新更新,则暂时以最近速度向前预测。为避免漂移,通过 extrapolationMs
(如 120–140ms)限制预测窗口;超出预算后停止预测并等待下一次权威更新。
2D 与 3D
默认位置与速度为二维;如需简单 3D,可添加 z
。若定义了 worldBounds.depth
,Z 轴同样会被限制。
世界边界 vs 开放世界
设定 worldBounds
时,位置被限制在 [0..width]
与 [0..height]
(若有 Z 则至 [0..depth]
)。
开放世界沙盒中,可设置 ignoreWorldBounds: true
以禁用所有限制(仅保留玩家间碰撞)。
碰撞(圆/球)
碰撞通过对半分离处理:圆(2D)/球(3D)半径相同,重叠时计算规范化向量并将双方沿该向量各推开一半重叠距离。简单且稳定,适合休闲类游戏。
// 两个玩家 A、B,半径 r
const dx = B.x - A.x, dy = B.y - A.y, dz = (B.z||0) - (A.z||0);
const dist = Math.max(1e-6, Math.hypot(dx, dy, dz));
const overlap = Math.max(0, 2*r - dist) / 2;
const nx = dx / dist, ny = dy / dist, nz = dz / dist;
A.x -= nx * overlap; A.y -= ny * overlap; A.z = (A.z||0) - nz * overlap;
B.x += nx * overlap; B.y += ny * overlap; B.z = (B.z||0) + nz * overlap;
O(n²)
),小房间可接受。若场景拥挤,可在应用层加入空间划分(网格/象限树)。
流程:移动步骤
网络细节
- 背压策略:对饱和通道进行合并/丢弃。
- 容量:强制
maxPlayers
并触发maxCapacityReached
事件。 - STUN/TURN:在严格网络下提供 TURN;信令使用 WSS。
背压
背压用于保护 DataChannel 不被过载。当通道内部发送缓冲(RTCDataChannel.bufferedAmount
)超阈值时,
可以暂时停止发送、丢弃低价值消息,或将多条更新折叠为最新一条。
send()
都会增加 bufferedAmount
,直到浏览器将数据刷到网络。
若发送速度长期高于网络承载能力,将导致延迟飙升与应用卡顿。以下策略可缓解。
策略
- off:不启用背压。仅适用于很小且不频繁的消息。
- drop-moves:超过阈值时忽略新的
move
消息(输入瞬态,丢弃通常可接受)。 - coalesce-moves:队列中仅保留每个对端的最新
move
,替换旧的。
const multiP2PGame = new P2PGameLibrary({
signaling,
backpressure: { strategy: 'coalesce-moves', thresholdBytes: 256 * 1024 }
});
事件和API(选摘)
on('playerMove')
、on('inventoryUpdate')
、on('objectTransfer')
on('stateSync')
、on('stateDelta')
、on('hostChange')
、on('ping')
broadcastMove()
、updateInventory()
、transferItem()
broadcastPayload()
、sendPayload()
setStateAndBroadcast()
、announcePresence()
、getHostId()
事件总览
事件 | 签名 | 说明 |
---|---|---|
playerMove | (playerId, position) | 已应用的移动 |
inventoryUpdate | (playerId, items) | 库存已更新 |
objectTransfer | (from, to, item) | 物品已转移 |
sharedPayload | (from, payload, channel?) | 收到通用载荷 |
stateSync | (state) | 收到全量快照 |
stateDelta | (delta) | 收到状态增量 |
peerJoin | (playerId) | 有对端加入 |
peerLeave | (playerId) | 有对端离开 |
hostChange | (hostId) | 主机变更 |
ping | (playerId, ms) | 往返时延 (RTT) |
maxCapacityReached | (maxPlayers) | 达到容量上限;拒绝新连接 |
生命周期与在线状态
- 在线状态:尽早调用
announcePresence(playerId)
发送一次初始移动,便于其他对端立即渲染该玩家。 - peerJoin/peerLeave:UI 可据此显示/隐藏实体。主机侧可通过在
P2PGameLibrary
配置中启用cleanupOnPeerLeave: true
来自动清理离开者并广播增量。 - 容量上限:设置
maxPlayers
以限制房间大小。达到容量后,库将不再发起新连接并忽略额外的 offer;同时触发maxCapacityReached(maxPlayers)
以便 UI 提示。
类型参考
type SerializationStrategy = "json" | "binary-min";
type SyncStrategy = "full" | "delta";
type ConflictResolution = "timestamp" | "authoritative";
interface BackpressureOptions {
strategy?: "off" | "drop-moves" | "coalesce-moves";
thresholdBytes?: number; // 默认约 256KB
}
interface DebugOptions {
enabled?: boolean;
onSend?: (info: {
type: "broadcast" | "send";
to: string | "all";
payloadBytes: number;
delivered: number;
queued: number;
serialization: SerializationStrategy;
timestamp: number;
}) => void;
}
interface MovementOptions {
maxSpeed?: number;
smoothing?: number; // 0..1
extrapolationMs?: number;
worldBounds?: { width: number; height: number; depth?: number };
ignoreWorldBounds?: boolean;
playerRadius?: number;
}
interface GameLibOptions {
maxPlayers?: number;
syncStrategy?: SyncStrategy; // 建议:由你决定何时发送 full vs delta
conflictResolution?: ConflictResolution;
authoritativeClientId?: string;
serialization?: SerializationStrategy;
iceServers?: RTCIceServer[];
cleanupOnPeerLeave?: boolean;
debug?: DebugOptions;
backpressure?: BackpressureOptions;
pingOverlay?: { enabled?: boolean; position?: "top-left"|"top-right"|"bottom-left"|"bottom-right"; canvas?: HTMLCanvasElement | null };
movement?: MovementOptions;
}
Events
type EventMap = {
playerMove: (playerId: string, position: { x:number; y:number; z?:number }) => void;
inventoryUpdate: (playerId: string, items: Array<{ id:string; type:string; quantity:number }>) => void;
objectTransfer: (fromId: string, toId: string, item: { id:string; type:string; quantity:number }) => void;
stateSync: (state: GlobalGameState) => void;
stateDelta: (delta: StateDelta) => void;
peerJoin: (playerId: string) => void;
peerLeave: (playerId: string) => void;
hostChange: (hostId: string) => void;
ping: (playerId: string, ms: number) => void;
sharedPayload: (from: string, payload: unknown, channel?: string) => void;
maxCapacityReached: (maxPlayers: number) => void;
};
interface GlobalGameState {
players: Record;
inventories: Record>;
objects: Record }>;
tick: number;
}
interface StateDelta { tick:number; changes: Array<{ path:string; value:unknown }> }
增量路径规则
- 路径使用点分隔的对象键(不支持数组索引)。
- 尽量保持结构扁平且可键控,以便定向更新(如
objects.chest.42
),避免深层数组。
// 推荐:对象映射
{ path: 'objects.chest.42', value: { id:'chest.42', kind:'chest', data:{ opened:true } } }
// 不支持:数组索引路径,如 'objects[3]' 或 'players.list.0'
P2PGameLibrary
构造函数
new P2PGameLibrary(options: GameLibOptions & { signaling: WebSocketSignaling | SignalingAdapter })
生命周期
await start(): Promise
on(name: N, handler: EventMap[N]): () => void
getState(): GlobalGameState
getHostId(): string | undefined
setPingOverlayEnabled(enabled: boolean): void
tick(now?: number): void // 每帧应用一次插值/碰撞
状态工具
setStateAndBroadcast(selfId: string, changes: Array<{ path:string; value:unknown }>): string[]
broadcastFullState(selfId: string): void
broadcastDelta(selfId: string, paths: string[]): void
游戏相关 API
announcePresence(selfId: string, position = { x:0, y:0 }): void
broadcastMove(selfId: string, position: {x:number;y:number;z?:number}, velocity?: {x:number;y:number;z?:number}): void
updateInventory(selfId: string, items: Array<{ id:string; type:string; quantity:number }>): void
transferItem(selfId: string, to: string, item: { id:string; type:string; quantity:number }): void
载荷 API
broadcastPayload(selfId: string, payload: unknown, channel?: string): void
sendPayload(selfId: string, to: string, payload: unknown, channel?: string): void
消息(传输)
// NetMessage 联合(节选)
type NetMessage =
| { t:"move"; from:string; ts:number; seq?:number; position:{x:number;y:number;z?:number}; velocity?:{x:number;y:number;z?:number} }
| { t:"inventory"; from:string; ts:number; seq?:number; items:Array<{id:string;type:string;quantity:number}> }
| { t:"transfer"; from:string; ts:number; seq?:number; to:string; item:{id:string;type:string;quantity:number} }
| { t:"state_full"; from:string; ts:number; seq?:number; state: GlobalGameState }
| { t:"state_delta"; from:string; ts:number; seq?:number; delta: StateDelta }
| { t:"payload"; from:string; ts:number; seq?:number; payload: unknown; channel?: string };
// 序列化
// 策略:"json"(字符串帧)或 "binary-min"(ArrayBuffer UTF-8 JSON)
信令适配器
库使用该抽象来通过任意后端(WebSocket、REST 等)交换 SDP/ICE。
interface SignalingAdapter {
localId: string;
roomId?: string;
register(): Promise; // 加入房间并接收名册
announce(desc: RTCSessionDescriptionInit, to?: string): Promise;
onRemoteDescription(cb: (desc: RTCSessionDescriptionInit, from: string) => void): void;
onIceCandidate(cb: (candidate: RTCIceCandidateInit, from: string) => void): void;
onRoster(cb: (roster: string[]) => void): void;
sendIceCandidate(candidate: RTCIceCandidateInit, to?: string): Promise;
}
示例:极简自定义适配器(WebSocket)
使用普通 WebSocket 信令服务器实现该接口。
class SimpleWsSignaling implements SignalingAdapter {
constructor(public localId: string, public roomId: string, private url: string) {
this.ws = new WebSocket(this.url);
}
private ws: WebSocket;
private rosterCb?: (list: string[]) => void;
private descCb?: (d: RTCSessionDescriptionInit, from: string) => void;
private iceCb?: (c: RTCIceCandidateInit, from: string) => void;
async register(): Promise {
await new Promise((resolve) => {
this.ws.addEventListener('open', () => {
this.ws.send(JSON.stringify({ kind:'register', roomId: this.roomId, from: this.localId, announce:true }));
resolve();
});
});
this.ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.sys === 'roster' && this.rosterCb) this.rosterCb(msg.roster);
if (msg.kind === 'desc' && this.descCb) this.descCb(msg.payload, msg.from);
if (msg.kind === 'ice' && this.iceCb) this.iceCb(msg.payload, msg.from);
});
}
onRoster(cb: (roster: string[]) => void){ this.rosterCb = cb; }
onRemoteDescription(cb: (desc: RTCSessionDescriptionInit, from: string) => void){ this.descCb = cb; }
onIceCandidate(cb: (candidate: RTCIceCandidateInit, from: string) => void){ this.iceCb = cb; }
async announce(desc: RTCSessionDescriptionInit, to?: string): Promise {
this.ws.send(JSON.stringify({ kind:'desc', roomId:this.roomId, from:this.localId, to, payload: desc }));
}
async sendIceCandidate(candidate: RTCIceCandidateInit, to?: string): Promise {
this.ws.send(JSON.stringify({ kind:'ice', roomId:this.roomId, from:this.localId, to, payload: candidate }));
}
}
// 结合库使用
const signaling = new SimpleWsSignaling('alice','room-1','wss://your-signal.example');
await signaling.register();
const multiP2PGame = new P2PGameLibrary({ signaling });
await multiP2PGame.start();
示例:REST + 长轮询适配器
在无 WebSocket 的环境中,使用 HTTP 接口 + 轮询接收消息。
class RestPollingSignaling implements SignalingAdapter {
constructor(public localId: string, public roomId: string, private baseUrl: string) {}
private rosterCb?: (list: string[]) => void;
private descCb?: (d: RTCSessionDescriptionInit, from: string) => void;
private iceCb?: (c: RTCIceCandidateInit, from: string) => void;
private polling = false;
async register(): Promise {
await fetch(`${this.baseUrl}/register`, { method:'POST', headers:{ 'content-type':'application/json' }, body: JSON.stringify({ roomId:this.roomId, from:this.localId, announce:true }) });
this.polling = true;
void this.poll();
}
private async poll(): Promise {
while (this.polling) {
try {
const res = await fetch(`${this.baseUrl}/poll?roomId=${encodeURIComponent(this.roomId)}&from=${encodeURIComponent(this.localId)}`);
if (!res.ok) { await new Promise(r=>setTimeout(r,1000)); continue; }
const msgs = await res.json();
for (const msg of msgs) {
if (msg.sys === 'roster' && this.rosterCb) this.rosterCb(msg.roster);
if (msg.kind === 'desc' && this.descCb) this.descCb(msg.payload, msg.from);
if (msg.kind === 'ice' && this.iceCb) this.iceCb(msg.payload, msg.from);
}
} catch {
await new Promise(r=>setTimeout(r,1000));
}
}
}
onRoster(cb: (roster: string[]) => void){ this.rosterCb = cb; }
onRemoteDescription(cb: (desc: RTCSessionDescriptionInit, from: string) => void){ this.descCb = cb; }
onIceCandidate(cb: (candidate: RTCIceCandidateInit, from: string) => void){ this.iceCb = cb; }
async announce(desc: RTCSessionDescriptionInit, to?: string): Promise {
await fetch(`${this.baseUrl}/send`, { method:'POST', headers:{ 'content-type':'application/json' }, body: JSON.stringify({ kind:'desc', roomId:this.roomId, from:this.localId, to, payload: desc }) });
}
async sendIceCandidate(candidate: RTCIceCandidateInit, to?: string): Promise {
await fetch(`${this.baseUrl}/send`, { method:'POST', headers:{ 'content-type':'application/json' }, body: JSON.stringify({ kind:'ice', roomId:this.roomId, from:this.localId, to, payload: candidate }) });
}
}
// 用法
const restSignaling = new RestPollingSignaling('alice','room-1','https://your-signal.example');
await restSignaling.register();
const multiP2PGame2 = new P2PGameLibrary({ signaling: restSignaling });
await multiP2PGame2.start();
WebSocket信令
示例中所用的参考实现;协议:
{ sys:'roster', roster:string[] }
为广播;通过 to
进行定向消息。
new WebSocketSignaling(localId: string, roomId: string, serverUrl: string)
await signaling.register();
signaling.onRoster((list) => {/* 更新 UI */});
signaling.onRemoteDescription((desc, from) => {/* 交给 PeerManager */});
signaling.onIceCandidate((cand, from) => {/* 交给 PeerManager */});
消息格式
// 客户端 → 服务器(注册)
{ roomId: string, from: string, announce: true, kind: 'register' }
// 服务器 → 客户端(名册广播)
{ sys: 'roster', roomId: string, roster: string[] }
// 客户端 → 服务器(SDP/ICE,定向或房间内广播)
{ kind: 'desc'|'ice', roomId: string, from: string, to?: string, payload: any, announce?: true }
PeerManager(内部)
- 为每个对端维护一个 RTCPeerConnection 与一个 RTCDataChannel,并连接必要的回调。
- 在两个对端间,由字典序更小的
playerId
发起连接(创建 offer),另一方应答,以避免同时发起。 - 发出
peerJoin
、peerLeave
、hostChange
与ping
事件,并将解码后的网络消息转发为netMessage
。 - 背压:
off
:通道可用则始终发送。drop-moves
:当bufferedAmount
超过阈值时丢弃新的move
。coalesce-moves
:用最新的move
替换队列中较旧的。
- 容量:强制执行
maxPlayers
(不再初始化新连接;忽略多余 offer),并触发maxCapacityReached(maxPlayers)
。
EventBus(内部)
class EventBus {
on(name: N, fn: EventMap[N]): () => void
off(name: N, fn: EventMap[N]): void
emit(name: N, ...args: Parameters): void
}
Ping覆盖层
该覆盖层在页面上方渲染一个小仪表板,跟踪与每个连接对端的往返时延(RTT),并保留短历史(约 60 个采样)。 开发时可用它来发现抖动尖峰、验证是否走 TURN、比较各对端情况。
选项
{
enabled?: boolean; // 默认 false
position?: 'top-left'|'top-right'|'bottom-left'|'bottom-right'; // 默认 'top-right'
canvas?: HTMLCanvasElement | null; // 可提供自有 canvas,或让覆盖层自行创建
}
用法
const multiP2PGame = new P2PGameLibrary({ signaling, pingOverlay: { enabled: true, position: 'top-right' } });
// 运行时开关
multiP2PGame.setPingOverlayEnabled(false);
序列化
interface Serializer {
encode(msg: NetMessage): string | ArrayBuffer;
decode(data: string | ArrayBuffer): NetMessage;
}
function createSerializer(strategy: 'json'|'binary-min' = 'json'): Serializer
示例
权威主机应用意图
const isHost = () => multiP2PGame.getHostId() === localId;
multiP2PGame.on("sharedPayload", (from, payload, channel) => {
if (!isHost()) return;
if (channel === "move-intent" && typeof payload === "object") {
const p = payload as { pos:{x:number;y:number}; vel?:{x:number;y:number} };
multiP2PGame.broadcastMove(multiP2PGame.getHostId()!, p.pos, p.vel);
}
});
将临时载荷持久化到共享状态
multiP2PGame.on("sharedPayload", (from, payload, channel) => {
if (channel !== "status") return;
if (payload && typeof payload === "object" && "hp" in (payload as any)) {
multiP2PGame.setStateAndBroadcast(multiP2PGame.getHostId()!, [
{ path: `objects.playerStatus.${from}`, value: { id:`playerStatus.${from}`, kind:"playerStatus", data:{ hp:(payload as any).hp } } }
]);
}
});
选择性增量更新
const paths = multiP2PGame.setStateAndBroadcast(localId, [
{ path:"objects.chest.42", value:{ id:"chest.42", kind:"chest", data:{ opened:true } } }
]);
// paths == ["objects.chest.42"]
事件参考
playerMove
game.on('playerMove', (playerId, position) => {
drawAvatar(playerId, position);
});
inventoryUpdate
game.on('inventoryUpdate', (playerId, items) => {
ui.updateInventory(playerId, items);
});
objectTransfer
game.on('objectTransfer', (from, to, item) => {
ui.toast(`${from} 将 ${item.id} 交给了 ${to}`);
});
sharedPayload
game.on('sharedPayload', (from, payload, channel) => {
if (channel === 'chat') chat.add(from, (payload as any).text);
});
stateSync
game.on('stateSync', (state) => {
world.hydrate(state);
});
stateDelta
game.on('stateDelta', (delta) => {
world.applyDelta(delta);
});
peerJoin / peerLeave
game.on('peerJoin', (id) => ui.addPeer(id));
game.on('peerLeave', (id) => ui.removePeer(id));
hostChange
game.on('hostChange', (hostId) => ui.setHost(hostId));
ping
game.on('ping', (id, ms) => ui.setPing(id, ms));
maxCapacityReached
game.on('maxCapacityReached', (max) => ui.alert(`房间已满 (${max})`));
生产注意事项
- 准备 ICE(TURN)与安全信令(WSS)。
- 为公平起见,可考虑可信/无头主机的权威模式。
- 监控
RTCDataChannel.bufferedAmount
并调节背压。
重连与用户体验清单
- 对端掉线时显示“正在重连”,依赖名册检测恢复。
- 主机迁移后发送新的
state_full
以对齐客户端。 - 可选启用
cleanupOnPeerLeave
在离开时清理状态(仅主机)。
调试
const game = new P2PGameLibrary({
signaling,
debug: {
enabled: true,
onSend(info){
console.log('[send]', info.type, 'to', info.to, 'bytes=', info.payloadBytes, 'queued=', info.queued);
}
}
});
浏览器兼容性
- 最新版 Chrome/Firefox/Edge/Safari 支持 DataChannel;Safari 生产环境需 HTTPS/WSS。
- 企业/酒店网络建议部署 TURN;经中继时延会更高。
故障排除
WebRTC 连接建立失败
- 混合内容:确保页面与信令都用 HTTPS/WSS(浏览器会阻止 HTTPS 页面上的 WS)。
- 缺少 TURN:在企业/酒店网络下直连受限。于
iceServers
提供 TURN 凭证。 - CORS/防火墙:信令端需要接受来源;检查反代规则与端口(TLS 443)。
DataChannel 堵塞(高延迟、输入滞后)
- 背压:启用
coalesce-moves
或drop-moves
并调节thresholdBytes
(从 256–512 KB 开始)。 - 减小消息:优先增量;压缩载荷(binary‑min);向量量化(如 mm → cm)。
- 降低发送率:限制移动广播(如 30–60Hz),依靠插值补帧。
主机切换后对端不同步
- 确保新主机广播
state_full
(库会自动触发)。 - 客户端应应用全量快照并清理本地缓存(让插值稳定数帧)。
Safari 特定问题
- 除 localhost 外需 HTTPS/WSS。
- 检查 STUN/TURN URL 是否包含传输参数(如
?transport=udp
)。
游戏工作流
端到端模式:将网络、一致性与状态连接起来,适配不同游戏类型。
1) 实时竞技场(动作/射击)
- 一致性:从
timestamp
开始;如有可信主机可切换至authoritative
。 - 同步:稳定阶段用增量;主机迁移时偶发全量。
- 背压:
coalesce-moves
仅保留最新移动。
// 设置
const multiP2PGame = new P2PGameLibrary({ signaling, conflictResolution: 'timestamp', backpressure: { strategy: 'coalesce-moves' }, movement: { smoothing: 0.2, extrapolationMs: 120 } });
await multiP2PGame.start();
// 本地输入 → 广播移动(客户端预测由你的渲染器处理)
function onInput(vec){
const pos = getPredictedPosition(vec);
multiP2PGame.broadcastMove(localId, pos, vec);
}
multiP2PGame.on('playerMove', (id, pos) => renderPlayer(id, pos));
2) 合作 RPG(库存,主机应用意图)
- 一致性:
authoritative
(主机验证物品使用、门、宝箱)。 - 协议:客户端发送意图(载荷);主机修改共享状态并广播增量。
// 客户端仅向主机发送意图
function usePotion(){
multiP2PGame.sendPayload(localId, multiP2PGame.getHostId()!, { action: 'use-item', itemId: 'potion' }, 'intent');
}
// 主机处理意图并修改状态
const isHost = () => multiP2PGame.getHostId() === localId;
multiP2PGame.on('sharedPayload', (from, payload, channel) => {
if (!isHost() || channel !== 'intent') return;
if ((payload as any).action === 'use-item') {
const inv = getInventoryAfterUse(from, (payload as any).itemId);
multiP2PGame.setStateAndBroadcast(localId, [ { path: `inventories.${from}`, value: inv } ]);
}
});
3) 回合制战术(确定性,全量快照)
- 一致性:权威主机负责规则与回合顺序。
- 同步:每次移动广播小增量;每 N 回合发送全量以保底。
interface TurnMove { unitId:string; to:{x:number;y:number} }
multiP2PGame.on('sharedPayload', (from, payload, channel) => {
if (channel !== 'turn-move' || multiP2PGame.getHostId() !== localId) return;
const mv = payload as TurnMove;
const ok = validateMove(currentState, from, mv);
if (!ok) return; // 拒绝非法移动
applyMove(currentState, mv);
multiP2PGame.setStateAndBroadcast(localId, [ { path: `players.${from}.lastMove`, value: mv } ]);
});
// 每 10 回合发送一次全量快照
if (currentState.tick % 10 === 0) multiP2PGame.broadcastFullState(localId);
4) 派对游戏 + 大厅(容量与迁移)
- 设置
maxPlayers
保护用户体验;处理maxCapacityReached
提示用户。 - 利用名册展示大厅;离开时自动迁移主机。
const multiP2PGame = new P2PGameLibrary({ signaling, maxPlayers: 8 });
multiP2PGame.on('maxCapacityReached', (max) => showToast(`Room is full (${max})`));
multiP2PGame.on('hostChange', (host) => updateLobbyHost(host));
5) 开放世界沙盒(无边界,Z 轴)
- 禁用世界边界;仅保留玩家对玩家碰撞。
- 高频更新可使用
binary-min
以缩小载荷。
const multiP2PGame = new P2PGameLibrary({
signaling,
serialization: 'binary-min',
movement: { ignoreWorldBounds: true, playerRadius: 20, smoothing: 0.25, extrapolationMs: 140 }
});
词汇表
- SDP:会话描述协议;描述 WebRTC 使用的媒体/数据会话参数。
- ICE:交互式连接建立;发现对端间的网络路径(经由 STUN/TURN)。
- STUN:帮助客户端获知其公网地址的服务器;用于 NAT 穿透。
- TURN:在无法直连时转发流量的中继服务器。
- DataChannel:WebRTC 的双向数据通道,用于游戏消息。
- LWW:最后写入胜;按发送方序列判定最新更新为准。
链接
- GitHub: aguiran/p2play-js
- npm: @p2play-js/p2p-game