* docs(network): 添加网络模块文档和 CLI 支持 - 添加中英文网络模块文档 - 将 network、network-protocols、network-server 加入 CLI 模块列表 * feat(cli): 增强 Node.js 服务端适配器 - 添加 @esengine/network-server 依赖支持 - 生成完整的 ECS 游戏服务器项目结构 - 修复 network-server 包支持 ESM/CJS 双格式 - 添加 ws@8.18.0 解决 Node.js 24 兼容性问题 - 组件使用 @ECSComponent 装饰器注册 - tsconfig 启用 experimentalDecorators
16 KiB
16 KiB
网络同步系统 (Network)
@esengine/network 提供基于 TSRPC 的客户端-服务器网络同步解决方案,用于多人游戏的实体同步、输入处理和状态插值。
概述
网络模块由三个包组成:
| 包名 | 描述 |
|---|---|
@esengine/network |
客户端 ECS 插件 |
@esengine/network-protocols |
共享协议定义 |
@esengine/network-server |
服务器端实现 |
安装
# 客户端
npm install @esengine/network
# 服务器端
npm install @esengine/network-server
使用 CLI 快速创建服务端
推荐使用 ESEngine CLI 快速创建完整的游戏服务端项目:
# 创建项目目录
mkdir my-game-server && cd my-game-server
npm init -y
# 使用 CLI 初始化 Node.js 服务端
npx @esengine/cli init -p nodejs
CLI 会自动生成以下项目结构:
my-game-server/
├── src/
│ ├── index.ts # 入口文件
│ ├── server/
│ │ └── GameServer.ts # 网络服务器配置
│ └── game/
│ ├── Game.ts # ECS 游戏主类
│ ├── scenes/
│ │ └── MainScene.ts # 主场景
│ ├── components/ # ECS 组件
│ │ ├── PositionComponent.ts
│ │ └── VelocityComponent.ts
│ └── systems/ # ECS 系统
│ └── MovementSystem.ts
├── tsconfig.json
├── package.json
└── README.md
启动服务端:
# 开发模式(热重载)
npm run dev
# 生产模式
npm run start
快速开始
客户端
import { Core, Scene } from '@esengine/ecs-framework';
import {
NetworkPlugin,
NetworkIdentity,
NetworkTransform
} from '@esengine/network';
// 定义游戏场景
class GameScene extends Scene {
initialize(): void {
this.name = 'Game';
// 网络系统由 NetworkPlugin 自动添加
}
}
// 初始化 Core
Core.create({ debug: false });
const scene = new GameScene();
Core.setScene(scene);
// 安装网络插件
const networkPlugin = new NetworkPlugin();
await Core.installPlugin(networkPlugin);
// 注册预制体工厂
networkPlugin.registerPrefab('player', (scene, spawn) => {
const entity = scene.createEntity(`player_${spawn.netId}`);
const identity = entity.addComponent(new NetworkIdentity());
identity.netId = spawn.netId;
identity.ownerId = spawn.ownerId;
identity.isLocalPlayer = spawn.ownerId === networkPlugin.networkService.localClientId;
entity.addComponent(new NetworkTransform());
return entity;
});
// 连接服务器
const success = await networkPlugin.connect('ws://localhost:3000', 'PlayerName');
if (success) {
console.log('Connected!');
}
// 游戏循环
function gameLoop(dt: number) {
Core.update(dt);
}
// 断开连接
await networkPlugin.disconnect();
服务器端
使用 CLI 创建服务端项目后,默认生成的代码已经配置好了 GameServer:
import { GameServer } from '@esengine/network-server';
const server = new GameServer({
port: 3000,
roomConfig: {
maxPlayers: 16,
tickRate: 20
}
});
await server.start();
console.log('Server started on ws://localhost:3000');
核心概念
架构
客户端 服务器
┌────────────────┐ ┌────────────────┐
│ NetworkPlugin │◄──── WS ────► │ GameServer │
│ ├─ Service │ │ ├─ Room │
│ ├─ SyncSystem │ │ └─ Players │
│ ├─ SpawnSystem │ └────────────────┘
│ └─ InputSystem │
└────────────────┘
组件
NetworkIdentity
网络标识组件,每个网络同步的实体必须拥有:
class NetworkIdentity extends Component {
netId: number; // 网络唯一 ID
ownerId: number; // 所有者客户端 ID
bIsLocalPlayer: boolean; // 是否为本地玩家
bHasAuthority: boolean; // 是否有权限控制
}
NetworkTransform
网络变换组件,用于位置和旋转同步:
class NetworkTransform extends Component {
position: { x: number; y: number };
rotation: number;
velocity: { x: number; y: number };
}
系统
NetworkSyncSystem
处理服务器状态同步和插值:
- 接收服务器状态快照
- 将状态存入快照缓冲区
- 对远程实体进行插值平滑
NetworkSpawnSystem
处理实体的网络生成和销毁:
- 监听 Spawn/Despawn 消息
- 使用注册的预制体工厂创建实体
- 管理网络实体的生命周期
NetworkInputSystem
处理本地玩家输入的网络发送:
- 收集本地玩家输入
- 发送输入到服务器
- 支持移动和动作输入
API 参考
NetworkPlugin
class NetworkPlugin {
constructor(config: INetworkPluginConfig);
// 安装插件
install(services: ServiceContainer): void;
// 连接服务器
connect(playerName: string, roomId?: string): Promise<void>;
// 断开连接
disconnect(): void;
// 注册预制体工厂
registerPrefab(prefab: string, factory: PrefabFactory): void;
// 属性
readonly localPlayerId: number | null;
readonly isConnected: boolean;
}
配置选项:
| 属性 | 类型 | 必需 | 描述 |
|---|---|---|---|
serverUrl |
string |
是 | WebSocket 服务器地址 |
NetworkService
网络服务,管理 WebSocket 连接:
class NetworkService {
// 连接状态
readonly state: ENetworkState;
readonly isConnected: boolean;
readonly clientId: number | null;
readonly roomId: string | null;
// 连接控制
connect(serverUrl: string): Promise<void>;
disconnect(): void;
// 加入房间
join(playerName: string, roomId?: string): Promise<ResJoin>;
// 发送输入
sendInput(input: IPlayerInput): void;
// 事件回调
setCallbacks(callbacks: Partial<INetworkCallbacks>): void;
}
网络状态枚举:
enum ENetworkState {
Disconnected = 'disconnected',
Connecting = 'connecting',
Connected = 'connected',
Joining = 'joining',
Joined = 'joined'
}
回调接口:
interface INetworkCallbacks {
onConnected?: () => void;
onDisconnected?: () => void;
onJoined?: (clientId: number, roomId: string) => void;
onSync?: (msg: MsgSync) => void;
onSpawn?: (msg: MsgSpawn) => void;
onDespawn?: (msg: MsgDespawn) => void;
}
预制体工厂
type PrefabFactory = (scene: Scene, spawn: MsgSpawn) => Entity;
注册预制体工厂用于网络实体的创建:
networkPlugin.registerPrefab('enemy', (scene, spawn) => {
const entity = scene.createEntity(`enemy_${spawn.netId}`);
const identity = entity.addComponent(new NetworkIdentity());
identity.netId = spawn.netId;
identity.ownerId = spawn.ownerId;
entity.addComponent(new NetworkTransform());
entity.addComponent(new EnemyComponent());
return entity;
});
输入系统
NetworkInputSystem
class NetworkInputSystem extends EntitySystem {
// 添加移动输入
addMoveInput(x: number, y: number): void;
// 添加动作输入
addActionInput(action: string): void;
// 清除输入
clearInput(): void;
}
使用示例:
// 通过 NetworkPlugin 发送输入(推荐)
networkPlugin.sendMoveInput(0, 1); // 移动
networkPlugin.sendActionInput('jump'); // 动作
// 或直接使用 inputSystem
const inputSystem = networkPlugin.inputSystem;
if (keyboard.isPressed('W')) {
inputSystem.addMoveInput(0, 1);
}
if (keyboard.isPressed('Space')) {
inputSystem.addActionInput('jump');
}
状态同步
快照缓冲区
用于存储服务器状态快照并进行插值:
import { createSnapshotBuffer, type IStateSnapshot } from '@esengine/network';
const buffer = createSnapshotBuffer<IStateSnapshot>({
maxSnapshots: 30, // 最大快照数
interpolationDelay: 100 // 插值延迟 (ms)
});
// 添加快照
buffer.addSnapshot({
time: serverTime,
entities: states
});
// 获取插值状态
const interpolated = buffer.getInterpolatedState(clientTime);
变换插值器
线性插值器
import { createTransformInterpolator } from '@esengine/network';
const interpolator = createTransformInterpolator();
// 添加状态
interpolator.addState(time, { x: 0, y: 0, rotation: 0 });
// 获取插值结果
const state = interpolator.getInterpolatedState(currentTime);
Hermite 插值器
使用 Hermite 样条实现更平滑的插值:
import { createHermiteTransformInterpolator } from '@esengine/network';
const interpolator = createHermiteTransformInterpolator({
bufferSize: 10
});
// 添加带速度的状态
interpolator.addState(time, {
x: 100,
y: 200,
rotation: 0,
vx: 5,
vy: 0
});
// 获取平滑的插值结果
const state = interpolator.getInterpolatedState(currentTime);
客户端预测
实现客户端预测和服务器校正:
import { createClientPrediction } from '@esengine/network';
const prediction = createClientPrediction({
maxPredictedInputs: 60,
reconciliationThreshold: 0.1
});
// 预测输入
const seq = prediction.predict(inputState, currentState, (state, input) => {
// 应用输入到状态
return applyInput(state, input);
});
// 服务器校正
const corrected = prediction.reconcile(
serverState,
serverSeq,
(state, input) => applyInput(state, input)
);
服务器端
GameServer
import { GameServer } from '@esengine/network-server';
const server = new GameServer({
port: 3000,
roomConfig: {
maxPlayers: 16, // 房间最大玩家数
tickRate: 20 // 同步频率 (Hz)
}
});
// 启动服务器
await server.start();
// 获取房间
const room = server.getOrCreateRoom('room-id');
// 停止服务器
await server.stop();
Room
class Room {
readonly id: string;
readonly playerCount: number;
readonly isFull: boolean;
// 添加玩家
addPlayer(name: string, connection: Connection): IPlayer | null;
// 移除玩家
removePlayer(clientId: number): void;
// 获取玩家
getPlayer(clientId: number): IPlayer | undefined;
// 处理输入
handleInput(clientId: number, input: IPlayerInput): void;
// 销毁房间
destroy(): void;
}
玩家接口:
interface IPlayer {
clientId: number; // 客户端 ID
name: string; // 玩家名称
connection: Connection; // 连接对象
netId: number; // 网络实体 ID
}
协议类型
消息类型
// 状态同步消息
interface MsgSync {
time: number;
entities: IEntityState[];
}
// 实体状态
interface IEntityState {
netId: number;
pos?: Vec2;
rot?: number;
}
// 生成消息
interface MsgSpawn {
netId: number;
ownerId: number;
prefab: string;
pos: Vec2;
rot: number;
}
// 销毁消息
interface MsgDespawn {
netId: number;
}
// 输入消息
interface MsgInput {
input: IPlayerInput;
}
// 玩家输入
interface IPlayerInput {
seq?: number;
moveDir?: Vec2;
actions?: string[];
}
API 类型
// 加入请求
interface ReqJoin {
playerName: string;
roomId?: string;
}
// 加入响应
interface ResJoin {
clientId: number;
roomId: string;
playerCount: number;
}
蓝图节点
网络模块提供了可视化脚本支持的蓝图节点:
IsLocalPlayer- 检查实体是否为本地玩家IsServer- 检查是否运行在服务器端HasAuthority- 检查是否有权限控制实体GetNetworkId- 获取实体的网络 IDGetLocalPlayerId- 获取本地玩家 ID
服务令牌
用于依赖注入:
import {
NetworkServiceToken,
NetworkSyncSystemToken,
NetworkSpawnSystemToken,
NetworkInputSystemToken
} from '@esengine/network';
// 获取服务
const networkService = services.get(NetworkServiceToken);
实际示例
完整的多人游戏客户端
import { Core, Scene, EntitySystem, Matcher, Entity } from '@esengine/ecs-framework';
import {
NetworkPlugin,
NetworkIdentity,
NetworkTransform
} from '@esengine/network';
// 定义游戏场景
class GameScene extends Scene {
initialize(): void {
this.name = 'MultiplayerGame';
// 网络系统由 NetworkPlugin 自动添加
// 添加自定义系统
this.addSystem(new LocalInputHandler());
}
}
// 初始化
async function initGame() {
Core.create({ debug: false });
const scene = new GameScene();
Core.setScene(scene);
// 安装网络插件
const networkPlugin = new NetworkPlugin();
await Core.installPlugin(networkPlugin);
// 注册玩家预制体
networkPlugin.registerPrefab('player', (scene, spawn) => {
const entity = scene.createEntity(`player_${spawn.netId}`);
const identity = entity.addComponent(new NetworkIdentity());
identity.netId = spawn.netId;
identity.ownerId = spawn.ownerId;
identity.isLocalPlayer = spawn.ownerId === networkPlugin.networkService.localClientId;
entity.addComponent(new NetworkTransform());
// 如果是本地玩家,添加输入标记
if (identity.isLocalPlayer) {
entity.addComponent(new LocalInputComponent());
}
return entity;
});
// 连接服务器
const success = await networkPlugin.connect('ws://localhost:3000', 'Player1');
if (success) {
console.log('已连接!');
} else {
console.error('连接失败');
}
return networkPlugin;
}
// 游戏循环
function gameLoop(deltaTime: number) {
Core.update(deltaTime);
}
initGame();
处理输入
class LocalInputHandler extends EntitySystem {
private _networkPlugin: NetworkPlugin | null = null;
constructor() {
super(Matcher.empty().all(NetworkIdentity, LocalInputComponent));
}
protected onAddedToScene(): void {
// 获取 NetworkPlugin 引用
this._networkPlugin = Core.getPlugin(NetworkPlugin);
}
protected processEntity(entity: Entity, dt: number): void {
if (!this._networkPlugin) return;
const identity = entity.getComponent(NetworkIdentity)!;
if (!identity.isLocalPlayer) return;
// 读取键盘输入
let moveX = 0;
let moveY = 0;
if (keyboard.isPressed('A')) moveX -= 1;
if (keyboard.isPressed('D')) moveX += 1;
if (keyboard.isPressed('W')) moveY += 1;
if (keyboard.isPressed('S')) moveY -= 1;
if (moveX !== 0 || moveY !== 0) {
this._networkPlugin.sendMoveInput(moveX, moveY);
}
if (keyboard.isJustPressed('Space')) {
this._networkPlugin.sendActionInput('jump');
}
}
}
最佳实践
-
合理设置同步频率:根据游戏类型选择合适的
tickRate,动作游戏通常需要 20-60 Hz -
使用插值延迟:设置适当的
interpolationDelay来平衡延迟和平滑度 -
客户端预测:对于本地玩家使用客户端预测减少输入延迟
-
预制体管理:为每种网络实体类型注册对应的预制体工厂
-
权限检查:使用
bHasAuthority检查是否有权限修改实体 -
连接状态:监听连接状态变化,处理断线重连
networkService.setCallbacks({
onConnected: () => console.log('已连接'),
onDisconnected: () => {
console.log('已断开');
// 处理重连逻辑
}
});