联网
Estella 附带一个跨平台 WebSocket。createSocket 为平台返回正确的实现——Web 上是浏览器
WebSocket,微信小游戏上是 wx.connectSocket——都在一套 API 之后。在它之上,NetChannel
增加类型化消息与请求/响应。
连接与收发消息
Section titled “连接与收发消息”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) |
发送 string 或 ArrayBuffer;open 之前发送的数据会排队并在连接时冲刷。 |
close(code?, reason?) |
断开连接。 |
readyState |
'connecting' | 'open' | 'closing' | 'closed'。 |
on(event, fn) |
订阅 'open' / 'message' / 'close' / 'error';返回退订函数。 |
类型化消息与 RPC —— NetChannel
Section titled “类型化消息与 RPC —— NetChannel”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 挂起的请求。 |
状态复制——多人实体同步
Section titled “状态复制——多人实体同步”在传输层之上,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); // 返回连接 idconst 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 行)。
兴趣管理——谁看见什么
Section titled “兴趣管理——谁看见什么”默认每个客户端接收全部复制实体。世界大了以后,在服务器上装一个兴趣策略: 每个连接只接收与它相关的实体——进入兴趣的实体以全量 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 集。
客户端预测——零延迟输入
Section titled “客户端预测——零延迟输入”不开预测时,你自己的 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'),同一份代码即可作为服务器、客户端或离线单机运行。