跳转到内容

联网

Estella 附带一个跨平台 WebSocketcreateSocket 为平台返回正确的实现——Web 上是浏览器 WebSocket,微信小游戏上是 wx.connectSocket——都在一套 API 之后。在它之上,NetChannel 增加类型化消息与请求/响应。

import { createSocket } from 'esengine';
const socket = createSocket({ url: 'wss://example.com/game' });
socket.on('open', () => {
socket.send(JSON.stringify({ type: 'join', room: 'lobby' }));
});
socket.on('message', (data) => {
const msg = JSON.parse(data as string); // a message from the server
});
socket.on('close', (code, reason) => { /* reconnect? */ });
socket.on('error', (err) => { /* … */ });
socket.connect();
成员 说明
connect() 打开连接。
send(data) 发送 stringArrayBuffer;open 之前发送的数据会排队并在连接时冲刷。
close(code?, reason?) 断开连接。
readyState 'connecting' | 'open' | 'closing' | 'closed'
on(event, fn) 订阅 'open' / 'message' / 'close' / 'error';返回退订函数。

NetChannel按类型路由的类型化消息请求/响应包裹一个传输,于是你按消息 type 发送,而不必每帧手动解析:

import { NetChannel } from 'esengine';
const channel = new NetChannel(socket);
// Fire-and-forget events, routed by type.
const off = channel.on<{ x: number; y: number }>('move', (p) => { /* … */ });
channel.send('move', { x: 3, y: 5 });
// Request/response (RPC): resolves with the reply, rejects on timeout/remote error.
channel.handle<{ id: string }, { hp: number }>('getStats', async (req) => {
return { hp: 100 };
});
const stats = await channel.request<{ hp: number }>('getStats', { id: 'p1' }, 5000);
方法 说明
on(type, handler) 订阅一个消息类型;返回取消订阅函数。
send(type, payload) 发送一个类型化事件。
handle(type, handler) 注册一个 RPC 处理器;返回取消订阅函数。
request(type, payload, timeoutMs?) 发送一个 RPC;以回复 resolve。
dispose(reason?) 拆除通道并 reject 挂起的请求。

在传输层之上,Estella 内置服务器权威的复制层:在服务器上给实体挂上 Replicated,它就会在每个客户端生成,声明过的字段以二进制增量流式同步, 远端角色带快照插值渲染。一处声明驱动整个线格式——无需手写同步代码。

import { defineComponent, Net, Replicated, Transform, createMemoryTransportPair } from 'esengine';
// 哪些字段复制在组件上声明(内置组件如 Transform 在 C++ 侧
// 用 `replicated` 注解;用户组件在这里声明)。
const Health = defineComponent('Health', { hp: 100, regen: 1 }, {
replicatedFields: ['hp'],
});
// transport 在两端之间传帧。进程内(测试 / listen server)用 memory pair;
// 真实网络在两端各放一个 GameSocket。
const [serverEnd, clientEnd] = createMemoryTransportPair();
// --- 服务器 app(权威端) ---
const server = serverApp.getResource(Net).startServer();
const connectionId = server.attachConnection(serverEnd); // 返回连接 id
const e = serverApp.world.spawn('player');
serverApp.world.insert(e, Transform, { position: { x: 0, y: 0, z: 0 } });
serverApp.world.insert(e, Health, {});
serverApp.world.insert(e, Replicated, { owner: connectionId }); // 归该连接所有
const input = server.inputOf(connectionId); // 该客户端的最新输入
// server.clientIds —— 每 tick 轮询,为每个已连接玩家生成/回收一个 pawn。
// --- 客户端 app ---
const clientNet = clientApp.getResource(Net);
await clientNet.connect(clientEnd, { interpolationDelayTicks: 2 }); // 2 = 平滑延迟
clientNet.client?.sendInput({ move: { x: 1, y: 0 } }); // 每 tick 输入上行
  • 权威:只有服务器做模拟;客户端看到的是复制且插值后的状态(NetGhost 标记代理实体)。握手会对协议/ABI/schema 不匹配直接拒连,漂移的构建永远 不会静默失步。
  • 归属:Replicated.owner 把某连接的 sendInput 命令路由到它的实体; client.ownsEntity(e) 回答“这个 ghost 是不是我的”。
  • 编辑器预览:在 Play 模式下拉里选 2–4 Players——listen server 和 客户端视图并排运行,零联网配置。
  • 专用服务器:同一份玩法代码可在 Node 下无头运行—— import { loadEsengineModule, createHeadlessApp, runHeadless } from 'esengine/node'

完整范式(复制、每 tick 输入、客户端预测)见 Multiplayer Arena 示例(约 130 行)。

默认每个客户端接收全部复制实体。世界大了以后,在服务器上装一个兴趣策略: 每个连接只接收与它相关的实体——进入兴趣的实体以全量 spawn 到达,离开的实体 在客户端 despawn 其 ghost,delta 帧只携带兴趣内的内容。

import { radiusInterest } from 'esengine';
// 每个客户端看到它拥有的实体周围 800 单位内的实体。
server.setInterestPolicy(radiusInterest(800));
// 或任意自定义规则——返回相关子集(或 'all'):
server.setInterestPolicy(({ connectionId, world, candidates }) => {
const visible = new Set(candidates.filter((e) => isRelevantTo(world, e, connectionId)));
return visible;
});
  • 自有实体永不被剔除——策略藏不掉客户端自己的 pawn。
  • 重进无缝:重新 spawn 携带实体的当前状态。
  • radiusInterest 默认读 Transform.position(可用 { position } 覆盖); 无位置的实体恒相关;连接尚无带位置的自有实体时 fail-open(看到全部)。
  • 会话中安装/替换/移除策略都是安全的——下一 tick 会调和每个连接的 ghost 集。

不开预测时,你自己的 pawn 要等一整个来回才动。开启预测的方式是把服务器玩法 用的同一个“输入→状态”函数交给客户端——一个函数、两端共用,移动规则没有第二份:

// 唯一的移动规则,两端共用。
function applyMove(world, entity, actions, dt) {
const move = actions.move;
if (!move) return;
const pos = world.tryGet(entity, NetPos);
pos.x += move.x * SPEED * dt;
pos.y += move.y * SPEED * dt;
world.set(entity, NetPos, pos);
}
// 服务器玩法(FixedUpdate):用 tickInputOf 取每 tick 的输入。
const input = server.tickInputOf(repl.owner);
if (input) applyMove(world, e, input.actions, time.fixedDelta);
// 客户端:开启预测;每个固定 tick 调一次 sendInput。
await clientNet.connect(clientEnd, {
prediction: { apply: applyMove },
});
clientNet.client.sendInput({ move: { x: 1, y: 0 } }); // pawn 立刻动

工作原理:sendInput 立即把指令应用到你拥有的实体并存入待确认缓冲;服务器 每个固定 tick 恰好消费一条指令(tickInputOf——预测级玩法用它替代 inputOf)并确认已消费的 seq;客户端每个固定 tick 把自有实体重建为 最新权威状态 ⊕ 未确认指令的重放。因此服务器侧的修正(墙体、击退)永远获胜, 误预测不可能累积——连服务器不再重发的字段也会被拉回。自有实体绕过快照插值。

  • 每个固定 tick 都要发一条输入(空闲时发 { move: {x:0,y:0} } 也算)—— 队列干涸时服务器会重复上一条指令,沉默意味着“继续保持”,不是“停”。
  • Replicated.owner 在 spawn 时就要赋好——归属随 spawn 载荷走。
  • apply 只能依赖世界状态 + actions + dt:和解时它会被重放。
  • 平滑:prediction: { apply, smoothing: { halfLife: 0.08 } } 让修正 渐出(视觉误差每 halfLife 秒减半)而不是硬 snap;加 maxError 让真正的 传送仍然瞬移。纯表现层——模拟状态不可能因此漂移。
  • 超出简单套接字的场景NetChannel——按类型路由和 RPC 胜过一个巨大的 'message' 处理器 switch。
  • 设置 RPC 超时,让丢失的回复 reject 而不是永远挂起。
  • 把收到的状态推入组件(用 Commands),让玩法留在 ECS。
  • 连接前排队是自动的——connect() 之后就能立即 send
  • 在系统里做权威门控——检查 app.getResource(Net).role('server' / 'client' / 'offline'),同一份代码即可作为服务器、客户端或离线单机运行。