跳转到内容

联网

Estella 附带一个跨平台 WebSocket。createSocket 为平台返回正确的实现——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) 发送 string 或 ArrayBuffer;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 不匹配直接拒连,漂移的构建永远 不会静默失步。

  • schema 比的是字节布局,不只是字段名:握手比对字段名和它们的 wire shape。一端默认 0、另一端默认 false 的字段,名字相同、字节数不同, 光看名字发现不了。

  • ghost 由声明的 archetype 构建,不是把服务器的组件倒过去。 一次 spawn 携带三样 东西,而它们是三份不同的契约:身份(netId/owner/父节点/名字)、构建键、以及 一份只含已声明复制字段的 baseline。别的都不过线——没有 replicated 字段的组件 永远到不了客户端,组件里未声明的字段同样到不了。

    所以「一个代理要存在需要什么」必须说出来:

    import { registerReplicationArchetype, Replicated, Sprite } from 'esengine';
    registerReplicationArchetype('pawn', (world, entity) => {
    world.insert(entity, Sprite, { size: { x: 36, y: 36 }, layer: 2 });
    });
    // 服务器侧:实体开始复制时给出这个键。
    world.insert(e, Replicated, { owner: connectionId, archetype: 'pawn' });

    archetype 先跑,权威的 baseline 覆盖在上面——所以离开兴趣圈又回来的实体拿到的是 当前状态,不是一份新的默认值。客户端解析不了这个键时会拒绝这次 spawn, 而不是显示半个实体。

  • 结构本身也会复制,不只是值:给活着的实体加一个 replicated 组件会发送 它的完整状态,移除则会从每个 ghost 上移除。spawn → 组件加入 → 更新 → 组件移除 → despawn 全部过线,并按权威端发出的顺序抵达。

  • 链路必须可靠且有序:客户端按到达顺序应用收到的东西,因为那就是权威端 的顺序。attachConnection 和 Net.connect 收的是 ReliableOrderedTransport ——内置 socket 都是;自己写的 transport 声明 delivery: 'reliable-ordered', 做不到这个保证的会是编译错误,而不是一次静默失步。

  • 归属:Replicated.owner 把某连接的 sendInput 命令路由到它的实体; client.ownsEntity(e) 回答“这个 ghost 是不是我的”。

  • 编辑器预览:在 Play 模式下拉里选 2–4 Players——listen server 和 客户端视图并排运行,零联网配置。

  • 专用服务器:同一份玩法代码可在 Node 下无头运行—— import { loadEsengineModule, createHeadlessApp, runHeadless } from 'esengine/node'。 再用 flushPendingRegistrations(app) 装上项目在模块作用域注册的东西(与正式 Web 运行时同一道门),权威端跑的就是项目自己的系统,而不是它的副本。

  • 重连目前由你负责:Replicated.owner 是连接 id,玩家重连会拿到新的一个。 SDK 没有任何身份能跨越断开的 socket,所以稳定玩家 id、会话令牌和宽限期都归你的游戏。

完整范式(复制、每 tick 输入、客户端预测)见 Multiplayer Arena 示例(约 130 行)。它的 server/ 目录就是专用服务器那一半:同一个项目,无头运行,后面是一条真实的 WebSocket。

默认每个客户端接收全部复制实体。世界大了以后,在服务器上装一个兴趣策略: 每个连接只接收与它相关的实体——进入兴趣的实体以全量 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 } 覆盖);无位置的实体恒相关;连接尚无带位置的自有实体时 fail-open(看到全部)。
  • 会话中安装/替换/移除策略都是安全的——下一 tick 会调和每个连接的 ghost 集。

策略是按连接拿到整个population的,所以它读什么就读几遍。而 provider 每次 采样只准备一次空间索引,然后用它回答所有连接:

import { radiusInterestProvider } from 'esengine';
server.setInterestProvider(radiusInterestProvider(800));

它给出的答案与策略逐个实体一致,并且有两种模式——这是能力差别,不是降级:

读取器 行为 原因
默认的 Transform 索引跨采样保留,只移动真正动过的实体 引擎会报告一次合成改变了哪些世界变换
自定义 position 每次采样重建索引 没有人能知道一个任意函数何时会给出不同答案

10 万实体、32 个连接时,保留索引占 18% 单核,而每采样重建是 195%;每采样读 668 次位置而不是 10 万次。完全没有东西移动时读 0 次。

而且当没有实体进入、离开或移动时,任何连接都不会被查询。保留索引会说明自己 仍是同一份快照;拥有实体也没有易主的连接,手上握着的就是答案,于是原样留着。这个 规模下的静止采样从五分之一个核降到几微秒。

这说的是可见性,不是流量:你看得见的实体上有字段变了,照样会送到你手上,与你的 视野动没动无关。

保留索引带来两条结论,都不是换个写法就能绕开的:

  • 你传了 position,就走每采样重建。对任意函数来说这是正确答案,而且仍然远比 策略便宜。
  • 物理驱动和父节点驱动的移动都会到达索引,因为两者都经过同一次合成。自己去写 worldPosition 的游戏则不会——那个字段是合成的输出,不是输入。

不开预测时,你自己的 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'),同一份代码即可作为服务器、客户端或离线单机运行。