feat(rpc,network): 新增 RPC 库并迁移网络模块 (#364)
* feat(rpc,network): 新增 RPC 库并迁移网络模块 ## @esengine/rpc (新增) - 新增类型安全的 RPC 库,支持 WebSocket 通信 - 新增 RpcClient 类:connect/disconnect, call/send/on/off/once 方法 - 新增 RpcServer 类:Node.js WebSocket 服务端 - 新增编解码系统:支持 JSON 和 MessagePack - 新增 TextEncoder/TextDecoder polyfill,兼容微信小游戏平台 - 新增 WebSocketAdapter 接口,支持跨平台 WebSocket 抽象 ## @esengine/network (重构) - 重构 NetworkService:拆分为 RpcService 基类和 GameNetworkService - 新增 gameProtocol:类型安全的 API 和消息定义 - 新增类型安全便捷方法:sendInput(), onSync(), onSpawn(), onDespawn() - 更新 NetworkPlugin 使用新的服务架构 - 移除 TSRPC 依赖,改用 @esengine/rpc ## 文档 - 新增 RPC 模块文档(中英文) - 更新 Network 模块文档(中英文) - 更新侧边栏导航 * fix(network,cli): 修复 CI 构建和更新 CLI 适配器 ## 修复 - 在 tsconfig.build.json 添加 rpc 引用,修复类型声明生成 ## CLI 更新 - 更新 nodejs 适配器使用新的 @esengine/rpc - 生成的服务器代码使用 RpcServer 替代旧的 GameServer - 添加 ws 和 @types/ws 依赖 - 更新 README 模板中的客户端连接示例 * chore: 添加 CLI changeset * fix(ci): add @esengine/rpc to build and check scripts - Add rpc package to CI build step (must build before network) - Add rpc to type-check:framework, lint:framework, test:ci:framework * fix(rpc,network): fix tsconfig for declaration generation - Remove composite mode from rpc (not needed, causes CI issues) - Remove rpc from network project references (resolves via node_modules) - Remove unused references from network tsconfig.build.json
This commit is contained in:
59
.changeset/rpc-network-migration.md
Normal file
59
.changeset/rpc-network-migration.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
"@esengine/rpc": minor
|
||||||
|
"@esengine/network": minor
|
||||||
|
"@esengine/cli": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
## @esengine/rpc
|
||||||
|
|
||||||
|
### 新增 | Added
|
||||||
|
|
||||||
|
- 新增类型安全的 RPC 库,支持 WebSocket 通信
|
||||||
|
- 新增 `RpcClient` 类:connect/disconnect, call/send/on/off/once 方法
|
||||||
|
- 新增 `RpcServer` 类:Node.js WebSocket 服务端
|
||||||
|
- 新增编解码系统:支持 JSON 和 MessagePack
|
||||||
|
- 新增 TextEncoder/TextDecoder polyfill,兼容微信小游戏平台
|
||||||
|
- 新增 WebSocketAdapter 接口,支持跨平台 WebSocket 抽象
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- Add type-safe RPC library with WebSocket support
|
||||||
|
- Add `RpcClient` class with connect/disconnect, call/send/on/off/once methods
|
||||||
|
- Add `RpcServer` class for Node.js WebSocket server
|
||||||
|
- Add codec system with JSON and MessagePack support
|
||||||
|
- Add TextEncoder/TextDecoder polyfill for WeChat platform compatibility
|
||||||
|
- Add WebSocketAdapter interface for cross-platform WebSocket abstraction
|
||||||
|
|
||||||
|
## @esengine/network
|
||||||
|
|
||||||
|
### 变更 | Changed
|
||||||
|
|
||||||
|
- 重构 NetworkService:拆分为 `RpcService<P>` 基类和 `GameNetworkService` 游戏服务类
|
||||||
|
- 新增 `gameProtocol`:类型安全的 API (join/leave) 和消息 (input/sync/spawn/despawn)
|
||||||
|
- 新增类型安全的便捷方法:sendInput(), onSync(), onSpawn(), onDespawn()
|
||||||
|
- 更新 NetworkPlugin 使用新的服务架构
|
||||||
|
- 移除 TSRPC 依赖,改用 @esengine/rpc
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- Refactor NetworkService into `RpcService<P>` base class and `GameNetworkService`
|
||||||
|
- Add `gameProtocol` with type-safe API (join/leave) and messages (input/sync/spawn/despawn)
|
||||||
|
- Add type-safe convenience methods: sendInput(), onSync(), onSpawn(), onDespawn()
|
||||||
|
- Update NetworkPlugin to use new service architecture
|
||||||
|
- Remove TSRPC dependency, migrate to @esengine/rpc
|
||||||
|
|
||||||
|
## @esengine/cli
|
||||||
|
|
||||||
|
### 变更 | Changed
|
||||||
|
|
||||||
|
- 更新 Node.js 适配器使用 `@esengine/rpc` 和 `@esengine/network`
|
||||||
|
- 生成的服务器代码改用 `RpcServer` + `gameProtocol`
|
||||||
|
- 添加 `ws` 和 `@types/ws` 依赖
|
||||||
|
- 更新 README 模板中的客户端连接示例
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- Update Node.js adapter to use `@esengine/rpc` and `@esengine/network`
|
||||||
|
- Generated server code now uses `RpcServer` + `gameProtocol`
|
||||||
|
- Add `ws` and `@types/ws` dependencies
|
||||||
|
- Update client connection example in README template
|
||||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -72,6 +72,7 @@ jobs:
|
|||||||
pnpm --filter @esengine/procgen build
|
pnpm --filter @esengine/procgen build
|
||||||
pnpm --filter @esengine/pathfinding build
|
pnpm --filter @esengine/pathfinding build
|
||||||
pnpm --filter @esengine/network-protocols build
|
pnpm --filter @esengine/network-protocols build
|
||||||
|
pnpm --filter @esengine/rpc build
|
||||||
pnpm --filter @esengine/network build
|
pnpm --filter @esengine/network build
|
||||||
|
|
||||||
# 类型检查 (仅 framework 包)
|
# 类型检查 (仅 framework 包)
|
||||||
|
|||||||
@@ -251,7 +251,14 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '网络',
|
label: 'RPC 通信',
|
||||||
|
translations: { en: 'RPC' },
|
||||||
|
items: [
|
||||||
|
{ label: '概述', slug: 'modules/rpc', translations: { en: 'Overview' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '网络同步',
|
||||||
translations: { en: 'Network' },
|
translations: { en: 'Network' },
|
||||||
items: [
|
items: [
|
||||||
{ label: '概述', slug: 'modules/network', translations: { en: 'Overview' } },
|
{ label: '概述', slug: 'modules/network', translations: { en: 'Overview' } },
|
||||||
|
|||||||
@@ -1,41 +1,38 @@
|
|||||||
---
|
---
|
||||||
title: "Network System"
|
title: "Network System"
|
||||||
description: "TSRPC-based multiplayer game network synchronization solution"
|
description: "Type-safe multiplayer game network synchronization based on @esengine/rpc"
|
||||||
---
|
---
|
||||||
|
|
||||||
`@esengine/network` provides a TSRPC-based client-server network synchronization solution for multiplayer games, including entity synchronization, input handling, and state interpolation.
|
`@esengine/network` provides a type-safe network synchronization solution based on `@esengine/rpc` for multiplayer games, including entity synchronization, input handling, and state interpolation.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The network module consists of three packages:
|
The network module consists of two core packages:
|
||||||
|
|
||||||
| Package | Description |
|
| Package | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `@esengine/network` | Client-side ECS plugin |
|
| `@esengine/rpc` | Type-safe RPC communication library |
|
||||||
| `@esengine/network-protocols` | Shared protocol definitions |
|
| `@esengine/network` | RPC-based game networking plugin |
|
||||||
| `@esengine/network-server` | Server-side implementation |
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Client
|
|
||||||
npm install @esengine/network
|
npm install @esengine/network
|
||||||
|
|
||||||
# Server
|
|
||||||
npm install @esengine/network-server
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `@esengine/rpc` is automatically installed as a dependency.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Client Server
|
Client Server
|
||||||
┌────────────────┐ ┌────────────────┐
|
┌────────────────────┐ ┌────────────────┐
|
||||||
│ NetworkPlugin │◄──── WS ────► │ GameServer │
|
│ NetworkPlugin │◄── WS ───►│ RpcServer │
|
||||||
│ ├─ Service │ │ ├─ Room │
|
│ ├─ NetworkService │ │ ├─ Protocol │
|
||||||
│ ├─ SyncSystem │ │ └─ Players │
|
│ ├─ SyncSystem │ │ └─ Handlers │
|
||||||
│ ├─ SpawnSystem │ └────────────────┘
|
│ ├─ SpawnSystem │ └────────────────┘
|
||||||
│ └─ InputSystem │
|
│ └─ InputSystem │
|
||||||
└────────────────┘
|
└────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -69,61 +66,81 @@ networkPlugin.registerPrefab('player', (scene, spawn) => {
|
|||||||
const identity = entity.addComponent(new NetworkIdentity());
|
const identity = entity.addComponent(new NetworkIdentity());
|
||||||
identity.netId = spawn.netId;
|
identity.netId = spawn.netId;
|
||||||
identity.ownerId = spawn.ownerId;
|
identity.ownerId = spawn.ownerId;
|
||||||
identity.bIsLocalPlayer = spawn.ownerId === networkPlugin.networkService.clientId;
|
identity.bIsLocalPlayer = spawn.ownerId === networkPlugin.localPlayerId;
|
||||||
|
|
||||||
entity.addComponent(new NetworkTransform());
|
entity.addComponent(new NetworkTransform());
|
||||||
return entity;
|
return entity;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Connect to server
|
// Connect to server
|
||||||
const success = await networkPlugin.connect('ws://localhost:3000', 'PlayerName');
|
const success = await networkPlugin.connect({
|
||||||
|
url: 'ws://localhost:3000',
|
||||||
|
playerName: 'Player1',
|
||||||
|
roomId: 'room-1' // optional
|
||||||
|
});
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
console.log('Connected!');
|
console.log('Connected! Player ID:', networkPlugin.localPlayerId);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { GameServer } from '@esengine/network-server';
|
import { RpcServer } from '@esengine/rpc/server';
|
||||||
|
import { gameProtocol } from '@esengine/network';
|
||||||
|
|
||||||
const server = new GameServer({
|
const server = new RpcServer(gameProtocol, {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
roomConfig: {
|
onStart: (port) => console.log(`Server running on ws://localhost:${port}`)
|
||||||
maxPlayers: 16,
|
});
|
||||||
tickRate: 20
|
|
||||||
}
|
// Register API handlers
|
||||||
|
server.handle('join', async (input, ctx) => {
|
||||||
|
const playerId = generatePlayerId();
|
||||||
|
return { playerId, roomId: input.roomId ?? 'default' };
|
||||||
|
});
|
||||||
|
|
||||||
|
server.handle('leave', async (input, ctx) => {
|
||||||
|
// Handle player leaving
|
||||||
});
|
});
|
||||||
|
|
||||||
await server.start();
|
await server.start();
|
||||||
console.log('Server started on ws://localhost:3000');
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Setup with CLI
|
## Custom Protocol
|
||||||
|
|
||||||
We recommend using ESEngine CLI to quickly create a complete game server project:
|
You can define your own protocol using `@esengine/rpc`:
|
||||||
|
|
||||||
```bash
|
```typescript
|
||||||
mkdir my-game-server && cd my-game-server
|
import { rpc } from '@esengine/rpc';
|
||||||
npm init -y
|
|
||||||
npx @esengine/cli init -p nodejs
|
|
||||||
```
|
|
||||||
|
|
||||||
Generated project structure:
|
// Define custom protocol
|
||||||
|
export const myProtocol = rpc.define({
|
||||||
|
api: {
|
||||||
|
login: rpc.api<{ username: string }, { token: string }>(),
|
||||||
|
getData: rpc.api<{ id: number }, { data: object }>(),
|
||||||
|
},
|
||||||
|
msg: {
|
||||||
|
chat: rpc.msg<{ from: string; text: string }>(),
|
||||||
|
notification: rpc.msg<{ type: string; content: string }>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
```
|
// Create service with custom protocol
|
||||||
my-game-server/
|
import { RpcService } from '@esengine/network';
|
||||||
├── src/
|
|
||||||
│ ├── index.ts
|
const service = new RpcService(myProtocol);
|
||||||
│ ├── server/
|
await service.connect({ url: 'ws://localhost:3000' });
|
||||||
│ │ └── GameServer.ts
|
|
||||||
│ └── game/
|
// Type-safe API calls
|
||||||
│ ├── Game.ts
|
const result = await service.call('login', { username: 'test' });
|
||||||
│ ├── scenes/
|
console.log(result.token);
|
||||||
│ ├── components/
|
|
||||||
│ └── systems/
|
// Type-safe message listening
|
||||||
├── tsconfig.json
|
service.on('chat', (data) => {
|
||||||
└── package.json
|
console.log(`${data.from}: ${data.text}`);
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
165
docs/src/content/docs/en/modules/rpc/index.md
Normal file
165
docs/src/content/docs/en/modules/rpc/index.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
---
|
||||||
|
title: "RPC Library"
|
||||||
|
description: "Type-safe WebSocket RPC communication library"
|
||||||
|
---
|
||||||
|
|
||||||
|
`@esengine/rpc` is a lightweight, type-safe RPC (Remote Procedure Call) library designed for games and real-time applications.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Type-Safe**: Complete TypeScript type inference for API calls and message handling
|
||||||
|
- **Bidirectional**: Supports both request-response (API) and publish-subscribe (message) patterns
|
||||||
|
- **Flexible Codecs**: Built-in JSON and MessagePack codecs
|
||||||
|
- **Cross-Platform**: Works in browsers, Node.js, and WeChat Mini Games
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/rpc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Define Protocol
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { rpc } from '@esengine/rpc';
|
||||||
|
|
||||||
|
// Define protocol (shared between client and server)
|
||||||
|
export const chatProtocol = rpc.define({
|
||||||
|
api: {
|
||||||
|
// Request-response APIs
|
||||||
|
login: rpc.api<{ username: string }, { userId: string; token: string }>(),
|
||||||
|
sendMessage: rpc.api<{ text: string }, { messageId: string }>(),
|
||||||
|
},
|
||||||
|
msg: {
|
||||||
|
// One-way messages (publish-subscribe)
|
||||||
|
newMessage: rpc.msg<{ from: string; text: string; time: number }>(),
|
||||||
|
userJoined: rpc.msg<{ username: string }>(),
|
||||||
|
userLeft: rpc.msg<{ username: string }>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ChatProtocol = typeof chatProtocol;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Client
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { RpcClient } from '@esengine/rpc/client';
|
||||||
|
import { chatProtocol } from './protocol';
|
||||||
|
|
||||||
|
// Create client
|
||||||
|
const client = new RpcClient(chatProtocol, 'ws://localhost:3000', {
|
||||||
|
onConnect: () => console.log('Connected'),
|
||||||
|
onDisconnect: (reason) => console.log('Disconnected:', reason),
|
||||||
|
onError: (error) => console.error('Error:', error),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect to server
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Call API (type-safe)
|
||||||
|
const { userId, token } = await client.call('login', { username: 'player1' });
|
||||||
|
console.log('Logged in:', userId);
|
||||||
|
|
||||||
|
// Listen to messages (type-safe)
|
||||||
|
client.on('newMessage', (data) => {
|
||||||
|
console.log(`${data.from}: ${data.text}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
await client.call('sendMessage', { text: 'Hello!' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create Server
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { RpcServer } from '@esengine/rpc/server';
|
||||||
|
import { chatProtocol } from './protocol';
|
||||||
|
|
||||||
|
// Create server
|
||||||
|
const server = new RpcServer(chatProtocol, {
|
||||||
|
port: 3000,
|
||||||
|
onStart: (port) => console.log(`Server started: ws://localhost:${port}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register API handlers
|
||||||
|
server.handle('login', async (input, ctx) => {
|
||||||
|
const userId = generateUserId();
|
||||||
|
const token = generateToken();
|
||||||
|
|
||||||
|
// Broadcast user joined
|
||||||
|
server.broadcast('userJoined', { username: input.username });
|
||||||
|
|
||||||
|
return { userId, token };
|
||||||
|
});
|
||||||
|
|
||||||
|
server.handle('sendMessage', async (input, ctx) => {
|
||||||
|
const messageId = generateMessageId();
|
||||||
|
|
||||||
|
// Broadcast new message
|
||||||
|
server.broadcast('newMessage', {
|
||||||
|
from: ctx.clientId,
|
||||||
|
text: input.text,
|
||||||
|
time: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { messageId };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
await server.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### Protocol Definition
|
||||||
|
|
||||||
|
A protocol is the contract between client and server:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const protocol = rpc.define({
|
||||||
|
api: {
|
||||||
|
// API: Request-response pattern, client initiates, server handles and returns
|
||||||
|
methodName: rpc.api<InputType, OutputType>(),
|
||||||
|
},
|
||||||
|
msg: {
|
||||||
|
// Message: Publish-subscribe pattern, either side can send, other side listens
|
||||||
|
messageName: rpc.msg<DataType>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### API vs Messages
|
||||||
|
|
||||||
|
| Feature | API | Message |
|
||||||
|
|---------|-----|---------|
|
||||||
|
| Pattern | Request-Response | Publish-Subscribe |
|
||||||
|
| Return Value | Yes (Promise) | No |
|
||||||
|
| Confirmation | Waits for response | Fire-and-forget |
|
||||||
|
| Use Cases | Login, queries, operations | Real-time notifications, state sync |
|
||||||
|
|
||||||
|
## Codecs
|
||||||
|
|
||||||
|
Two built-in codecs are available:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { json, msgpack } from '@esengine/rpc/codec';
|
||||||
|
|
||||||
|
// JSON (default, human-readable)
|
||||||
|
const client = new RpcClient(protocol, url, {
|
||||||
|
codec: json(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// MessagePack (binary, more efficient)
|
||||||
|
const client = new RpcClient(protocol, url, {
|
||||||
|
codec: msgpack(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Client API](/en/modules/rpc/client/) - Detailed RpcClient API
|
||||||
|
- [Server API](/en/modules/rpc/server/) - Detailed RpcServer API
|
||||||
|
- [Codecs](/en/modules/rpc/codec/) - Custom codecs
|
||||||
@@ -13,10 +13,11 @@ class NetworkPlugin implements IPlugin {
|
|||||||
readonly version: string;
|
readonly version: string;
|
||||||
|
|
||||||
// 访问器
|
// 访问器
|
||||||
get networkService(): NetworkService;
|
get networkService(): GameNetworkService;
|
||||||
get syncSystem(): NetworkSyncSystem;
|
get syncSystem(): NetworkSyncSystem;
|
||||||
get spawnSystem(): NetworkSpawnSystem;
|
get spawnSystem(): NetworkSpawnSystem;
|
||||||
get inputSystem(): NetworkInputSystem;
|
get inputSystem(): NetworkInputSystem;
|
||||||
|
get localPlayerId(): number;
|
||||||
get isConnected(): boolean;
|
get isConnected(): boolean;
|
||||||
|
|
||||||
// 生命周期
|
// 生命周期
|
||||||
@@ -24,7 +25,10 @@ class NetworkPlugin implements IPlugin {
|
|||||||
uninstall(): void;
|
uninstall(): void;
|
||||||
|
|
||||||
// 连接管理
|
// 连接管理
|
||||||
connect(serverUrl: string, playerName: string, roomId?: string): Promise<boolean>;
|
connect(options: NetworkServiceOptions & {
|
||||||
|
playerName: string;
|
||||||
|
roomId?: string;
|
||||||
|
}): Promise<boolean>;
|
||||||
disconnect(): Promise<void>;
|
disconnect(): Promise<void>;
|
||||||
|
|
||||||
// 预制体注册
|
// 预制体注册
|
||||||
@@ -36,36 +40,65 @@ class NetworkPlugin implements IPlugin {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## NetworkService
|
## RpcService
|
||||||
|
|
||||||
网络服务,管理 WebSocket 连接。
|
通用 RPC 服务基类,支持自定义协议。
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
class NetworkService {
|
class RpcService<P extends ProtocolDef> {
|
||||||
// 访问器
|
// 访问器
|
||||||
get state(): ENetworkState;
|
get state(): NetworkState;
|
||||||
get isConnected(): boolean;
|
get isConnected(): boolean;
|
||||||
get clientId(): number;
|
get client(): RpcClient<P> | null;
|
||||||
get roomId(): string;
|
|
||||||
|
constructor(protocol: P);
|
||||||
|
|
||||||
// 连接管理
|
// 连接管理
|
||||||
connect(serverUrl: string, playerName: string, roomId?: string): Promise<boolean>;
|
connect(options: NetworkServiceOptions): Promise<void>;
|
||||||
disconnect(): Promise<void>;
|
disconnect(): void;
|
||||||
|
|
||||||
// 输入发送
|
// RPC 调用
|
||||||
sendInput(input: IPlayerInput): void;
|
call<K extends ApiNames<P>>(
|
||||||
|
name: K,
|
||||||
|
input: ApiInput<P['api'][K]>
|
||||||
|
): Promise<ApiOutput<P['api'][K]>>;
|
||||||
|
|
||||||
// 回调设置
|
// 消息发送
|
||||||
setCallbacks(callbacks: INetworkCallbacks): void;
|
send<K extends MsgNames<P>>(name: K, data: MsgData<P['msg'][K]>): void;
|
||||||
|
|
||||||
|
// 消息监听
|
||||||
|
on<K extends MsgNames<P>>(name: K, handler: (data: MsgData<P['msg'][K]>) => void): this;
|
||||||
|
off<K extends MsgNames<P>>(name: K, handler?: (data: MsgData<P['msg'][K]>) => void): this;
|
||||||
|
once<K extends MsgNames<P>>(name: K, handler: (data: MsgData<P['msg'][K]>) => void): this;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## GameNetworkService
|
||||||
|
|
||||||
|
游戏网络服务,继承自 RpcService,提供游戏特定的便捷方法。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class GameNetworkService extends RpcService<GameProtocol> {
|
||||||
|
// 发送玩家输入
|
||||||
|
sendInput(input: PlayerInput): void;
|
||||||
|
|
||||||
|
// 监听状态同步(链式调用)
|
||||||
|
onSync(handler: (data: SyncData) => void): this;
|
||||||
|
|
||||||
|
// 监听实体生成
|
||||||
|
onSpawn(handler: (data: SpawnData) => void): this;
|
||||||
|
|
||||||
|
// 监听实体销毁
|
||||||
|
onDespawn(handler: (data: DespawnData) => void): this;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 枚举类型
|
## 枚举类型
|
||||||
|
|
||||||
### ENetworkState
|
### NetworkState
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const enum ENetworkState {
|
const enum NetworkState {
|
||||||
Disconnected = 0,
|
Disconnected = 0,
|
||||||
Connecting = 1,
|
Connecting = 1,
|
||||||
Connected = 2
|
Connected = 2
|
||||||
@@ -74,15 +107,21 @@ const enum ENetworkState {
|
|||||||
|
|
||||||
## 接口类型
|
## 接口类型
|
||||||
|
|
||||||
### INetworkCallbacks
|
### NetworkServiceOptions
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface INetworkCallbacks {
|
interface NetworkServiceOptions extends RpcClientOptions {
|
||||||
onConnected?: (clientId: number, roomId: string) => void;
|
url: string;
|
||||||
onDisconnected?: () => void;
|
}
|
||||||
onSync?: (msg: MsgSync) => void;
|
```
|
||||||
onSpawn?: (msg: MsgSpawn) => void;
|
|
||||||
onDespawn?: (msg: MsgDespawn) => void;
|
### RpcClientOptions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RpcClientOptions {
|
||||||
|
codec?: Codec;
|
||||||
|
onConnect?: () => void;
|
||||||
|
onDisconnect?: (reason?: string) => void;
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -90,15 +129,15 @@ interface INetworkCallbacks {
|
|||||||
### PrefabFactory
|
### PrefabFactory
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type PrefabFactory = (scene: Scene, spawn: MsgSpawn) => Entity;
|
type PrefabFactory = (scene: Scene, spawn: SpawnData) => Entity;
|
||||||
```
|
```
|
||||||
|
|
||||||
### IPlayerInput
|
### PlayerInput
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface IPlayerInput {
|
interface PlayerInput {
|
||||||
seq?: number;
|
frame: number;
|
||||||
moveDir?: Vec2;
|
moveDir?: { x: number; y: number };
|
||||||
actions?: string[];
|
actions?: string[];
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -170,86 +209,128 @@ const networkService = services.get(NetworkServiceToken);
|
|||||||
|
|
||||||
## 服务器端 API
|
## 服务器端 API
|
||||||
|
|
||||||
### GameServer
|
### RpcServer
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
class GameServer {
|
class RpcServer<P extends ProtocolDef> {
|
||||||
constructor(config: IGameServerConfig);
|
constructor(protocol: P, options: RpcServerOptions);
|
||||||
|
|
||||||
|
// 启动/停止服务器
|
||||||
start(): Promise<void>;
|
start(): Promise<void>;
|
||||||
stop(): Promise<void>;
|
stop(): void;
|
||||||
|
|
||||||
getOrCreateRoom(roomId: string): Room;
|
// 注册 API 处理器
|
||||||
getRoom(roomId: string): Room | undefined;
|
handle<K extends ApiNames<P>>(
|
||||||
destroyRoom(roomId: string): void;
|
name: K,
|
||||||
|
handler: (input: ApiInput<P['api'][K]>, ctx: RpcContext) => Promise<ApiOutput<P['api'][K]>>
|
||||||
|
): void;
|
||||||
|
|
||||||
|
// 广播消息
|
||||||
|
broadcast<K extends MsgNames<P>>(name: K, data: MsgData<P['msg'][K]>): void;
|
||||||
|
|
||||||
|
// 发送给特定客户端
|
||||||
|
sendTo<K extends MsgNames<P>>(clientId: string, name: K, data: MsgData<P['msg'][K]>): void;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Room
|
### RpcServerOptions
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
class Room {
|
interface RpcServerOptions {
|
||||||
readonly id: string;
|
port: number;
|
||||||
readonly playerCount: number;
|
codec?: Codec;
|
||||||
readonly isFull: boolean;
|
onStart?: (port: number) => void;
|
||||||
|
onConnection?: (clientId: string) => void;
|
||||||
addPlayer(name: string, connection: Connection): IPlayer | null;
|
onDisconnection?: (clientId: string, reason?: string) => void;
|
||||||
removePlayer(clientId: number): void;
|
onError?: (error: Error) => void;
|
||||||
getPlayer(clientId: number): IPlayer | undefined;
|
|
||||||
handleInput(clientId: number, input: IPlayerInput): void;
|
|
||||||
destroy(): void;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### IPlayer
|
### RpcContext
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface IPlayer {
|
interface RpcContext {
|
||||||
clientId: number;
|
clientId: string;
|
||||||
name: string;
|
|
||||||
connection: Connection;
|
|
||||||
netId: number;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 协议消息
|
## 协议定义
|
||||||
|
|
||||||
### MsgSync
|
### gameProtocol
|
||||||
|
|
||||||
|
默认游戏协议,包含加入/离开 API 和状态同步消息:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface MsgSync {
|
const gameProtocol = rpc.define({
|
||||||
|
api: {
|
||||||
|
join: rpc.api<JoinRequest, JoinResponse>(),
|
||||||
|
leave: rpc.api<void, void>(),
|
||||||
|
},
|
||||||
|
msg: {
|
||||||
|
input: rpc.msg<PlayerInput>(),
|
||||||
|
sync: rpc.msg<SyncData>(),
|
||||||
|
spawn: rpc.msg<SpawnData>(),
|
||||||
|
despawn: rpc.msg<DespawnData>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 协议消息类型
|
||||||
|
|
||||||
|
### SyncData
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SyncData {
|
||||||
time: number;
|
time: number;
|
||||||
entities: IEntityState[];
|
entities: IEntityState[];
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### MsgSpawn
|
### SpawnData
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface MsgSpawn {
|
interface SpawnData {
|
||||||
netId: number;
|
netId: number;
|
||||||
ownerId: number;
|
ownerId: number;
|
||||||
prefab: string;
|
prefab: string;
|
||||||
pos: Vec2;
|
position: { x: number; y: number };
|
||||||
rot: number;
|
rotation: number;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### MsgDespawn
|
### DespawnData
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface MsgDespawn {
|
interface DespawnData {
|
||||||
netId: number;
|
netId: number;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### IEntityState
|
### JoinRequest
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface IEntityState {
|
interface JoinRequest {
|
||||||
|
playerName: string;
|
||||||
|
roomId?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JoinResponse
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface JoinResponse {
|
||||||
|
playerId: number;
|
||||||
|
roomId: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### EntitySyncState
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface EntitySyncState {
|
||||||
netId: number;
|
netId: number;
|
||||||
pos?: Vec2;
|
position?: { x: number; y: number };
|
||||||
rot?: number;
|
rotation?: number;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: "NetworkPlugin、组件和系统的客户端使用指南"
|
|||||||
|
|
||||||
## NetworkPlugin
|
## NetworkPlugin
|
||||||
|
|
||||||
NetworkPlugin 是客户端网络功能的核心入口。
|
NetworkPlugin 是客户端网络功能的核心入口,基于 `@esengine/rpc` 提供类型安全的网络通信。
|
||||||
|
|
||||||
### 基本用法
|
### 基本用法
|
||||||
|
|
||||||
@@ -18,7 +18,11 @@ const networkPlugin = new NetworkPlugin();
|
|||||||
await Core.installPlugin(networkPlugin);
|
await Core.installPlugin(networkPlugin);
|
||||||
|
|
||||||
// 连接服务器
|
// 连接服务器
|
||||||
const success = await networkPlugin.connect('ws://localhost:3000', 'PlayerName');
|
const success = await networkPlugin.connect({
|
||||||
|
url: 'ws://localhost:3000',
|
||||||
|
playerName: 'PlayerName',
|
||||||
|
roomId: 'room-1' // 可选
|
||||||
|
});
|
||||||
|
|
||||||
// 断开连接
|
// 断开连接
|
||||||
await networkPlugin.disconnect();
|
await networkPlugin.disconnect();
|
||||||
@@ -32,14 +36,19 @@ class NetworkPlugin {
|
|||||||
readonly version: string;
|
readonly version: string;
|
||||||
|
|
||||||
// 访问器
|
// 访问器
|
||||||
get networkService(): NetworkService;
|
get networkService(): GameNetworkService;
|
||||||
get syncSystem(): NetworkSyncSystem;
|
get syncSystem(): NetworkSyncSystem;
|
||||||
get spawnSystem(): NetworkSpawnSystem;
|
get spawnSystem(): NetworkSpawnSystem;
|
||||||
get inputSystem(): NetworkInputSystem;
|
get inputSystem(): NetworkInputSystem;
|
||||||
|
get localPlayerId(): number;
|
||||||
get isConnected(): boolean;
|
get isConnected(): boolean;
|
||||||
|
|
||||||
// 连接服务器
|
// 连接服务器
|
||||||
connect(serverUrl: string, playerName: string, roomId?: string): Promise<boolean>;
|
connect(options: {
|
||||||
|
url: string;
|
||||||
|
playerName: string;
|
||||||
|
roomId?: string;
|
||||||
|
}): Promise<boolean>;
|
||||||
|
|
||||||
// 断开连接
|
// 断开连接
|
||||||
disconnect(): Promise<void>;
|
disconnect(): Promise<void>;
|
||||||
@@ -77,7 +86,7 @@ networkPlugin.registerPrefab('player', (scene, spawn) => {
|
|||||||
const identity = entity.addComponent(new NetworkIdentity());
|
const identity = entity.addComponent(new NetworkIdentity());
|
||||||
identity.netId = spawn.netId;
|
identity.netId = spawn.netId;
|
||||||
identity.ownerId = spawn.ownerId;
|
identity.ownerId = spawn.ownerId;
|
||||||
identity.bIsLocalPlayer = spawn.ownerId === networkPlugin.networkService.clientId;
|
identity.bIsLocalPlayer = spawn.ownerId === networkPlugin.localPlayerId;
|
||||||
|
|
||||||
return entity;
|
return entity;
|
||||||
});
|
});
|
||||||
@@ -156,7 +165,7 @@ networkPlugin.registerPrefab('player', (scene, spawn) => {
|
|||||||
const identity = entity.addComponent(new NetworkIdentity());
|
const identity = entity.addComponent(new NetworkIdentity());
|
||||||
identity.netId = spawn.netId;
|
identity.netId = spawn.netId;
|
||||||
identity.ownerId = spawn.ownerId;
|
identity.ownerId = spawn.ownerId;
|
||||||
identity.bIsLocalPlayer = spawn.ownerId === networkPlugin.networkService.clientId;
|
identity.bIsLocalPlayer = spawn.ownerId === networkPlugin.localPlayerId;
|
||||||
|
|
||||||
entity.addComponent(new NetworkTransform());
|
entity.addComponent(new NetworkTransform());
|
||||||
|
|
||||||
@@ -230,18 +239,33 @@ class LocalInputHandler extends EntitySystem {
|
|||||||
|
|
||||||
## 连接状态监听
|
## 连接状态监听
|
||||||
|
|
||||||
|
使用 `GameNetworkService` 的链式 API 监听消息:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
networkPlugin.networkService.setCallbacks({
|
const { networkService } = networkPlugin;
|
||||||
onConnected: (clientId, roomId) => {
|
|
||||||
console.log(`已连接: 客户端 ${clientId}, 房间 ${roomId}`);
|
// 监听状态同步
|
||||||
},
|
networkService.onSync((data) => {
|
||||||
onDisconnected: () => {
|
console.log('收到同步数据:', data.entities.length, '个实体');
|
||||||
console.log('已断开');
|
});
|
||||||
// 处理重连逻辑
|
|
||||||
},
|
// 监听实体生成
|
||||||
onError: (error) => {
|
networkService.onSpawn((data) => {
|
||||||
console.error('网络错误:', error);
|
console.log('生成实体:', data.prefab, 'netId:', data.netId);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// 监听实体销毁
|
||||||
|
networkService.onDespawn((data) => {
|
||||||
|
console.log('销毁实体:', data.netId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通过连接选项设置回调
|
||||||
|
await networkPlugin.connect({
|
||||||
|
url: 'ws://localhost:3000',
|
||||||
|
playerName: 'Player1',
|
||||||
|
onConnect: () => console.log('已连接'),
|
||||||
|
onDisconnect: (reason) => console.log('已断开:', reason),
|
||||||
|
onError: (error) => console.error('网络错误:', error)
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,38 @@
|
|||||||
---
|
---
|
||||||
title: "网络同步系统 (Network)"
|
title: "网络同步系统 (Network)"
|
||||||
description: "基于 TSRPC 的多人游戏网络同步解决方案"
|
description: "基于 @esengine/rpc 的多人游戏网络同步解决方案"
|
||||||
---
|
---
|
||||||
|
|
||||||
`@esengine/network` 提供基于 TSRPC 的客户端-服务器网络同步解决方案,用于多人游戏的实体同步、输入处理和状态插值。
|
`@esengine/network` 提供基于 `@esengine/rpc` 的类型安全网络同步解决方案,用于多人游戏的实体同步、输入处理和状态插值。
|
||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
网络模块由三个包组成:
|
网络模块由两个核心包组成:
|
||||||
|
|
||||||
| 包名 | 描述 |
|
| 包名 | 描述 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `@esengine/network` | 客户端 ECS 插件 |
|
| `@esengine/rpc` | 类型安全的 RPC 通信库 |
|
||||||
| `@esengine/network-protocols` | 共享协议定义 |
|
| `@esengine/network` | 基于 RPC 的游戏网络插件 |
|
||||||
| `@esengine/network-server` | 服务器端实现 |
|
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 客户端
|
|
||||||
npm install @esengine/network
|
npm install @esengine/network
|
||||||
|
|
||||||
# 服务器端
|
|
||||||
npm install @esengine/network-server
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `@esengine/rpc` 会作为依赖自动安装。
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
客户端 服务器
|
客户端 服务器
|
||||||
┌────────────────┐ ┌────────────────┐
|
┌────────────────────┐ ┌────────────────┐
|
||||||
│ NetworkPlugin │◄──── WS ────► │ GameServer │
|
│ NetworkPlugin │◄── WS ───►│ RpcServer │
|
||||||
│ ├─ Service │ │ ├─ Room │
|
│ ├─ NetworkService │ │ ├─ Protocol │
|
||||||
│ ├─ SyncSystem │ │ └─ Players │
|
│ ├─ SyncSystem │ │ └─ Handlers │
|
||||||
│ ├─ SpawnSystem │ └────────────────┘
|
│ ├─ SpawnSystem │ └────────────────┘
|
||||||
│ └─ InputSystem │
|
│ └─ InputSystem │
|
||||||
└────────────────┘
|
└────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@@ -69,61 +66,81 @@ networkPlugin.registerPrefab('player', (scene, spawn) => {
|
|||||||
const identity = entity.addComponent(new NetworkIdentity());
|
const identity = entity.addComponent(new NetworkIdentity());
|
||||||
identity.netId = spawn.netId;
|
identity.netId = spawn.netId;
|
||||||
identity.ownerId = spawn.ownerId;
|
identity.ownerId = spawn.ownerId;
|
||||||
identity.bIsLocalPlayer = spawn.ownerId === networkPlugin.networkService.clientId;
|
identity.bIsLocalPlayer = spawn.ownerId === networkPlugin.localPlayerId;
|
||||||
|
|
||||||
entity.addComponent(new NetworkTransform());
|
entity.addComponent(new NetworkTransform());
|
||||||
return entity;
|
return entity;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 连接服务器
|
// 连接服务器
|
||||||
const success = await networkPlugin.connect('ws://localhost:3000', 'PlayerName');
|
const success = await networkPlugin.connect({
|
||||||
|
url: 'ws://localhost:3000',
|
||||||
|
playerName: 'Player1',
|
||||||
|
roomId: 'room-1' // 可选
|
||||||
|
});
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
console.log('Connected!');
|
console.log('Connected! Player ID:', networkPlugin.localPlayerId);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 服务器端
|
### 服务器端
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { GameServer } from '@esengine/network-server';
|
import { RpcServer } from '@esengine/rpc/server';
|
||||||
|
import { gameProtocol } from '@esengine/network';
|
||||||
|
|
||||||
const server = new GameServer({
|
const server = new RpcServer(gameProtocol, {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
roomConfig: {
|
onStart: (port) => console.log(`Server running on ws://localhost:${port}`)
|
||||||
maxPlayers: 16,
|
});
|
||||||
tickRate: 20
|
|
||||||
}
|
// 注册 API 处理器
|
||||||
|
server.handle('join', async (input, ctx) => {
|
||||||
|
const playerId = generatePlayerId();
|
||||||
|
return { playerId, roomId: input.roomId ?? 'default' };
|
||||||
|
});
|
||||||
|
|
||||||
|
server.handle('leave', async (input, ctx) => {
|
||||||
|
// 处理玩家离开
|
||||||
});
|
});
|
||||||
|
|
||||||
await server.start();
|
await server.start();
|
||||||
console.log('Server started on ws://localhost:3000');
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 使用 CLI 快速创建
|
## 自定义协议
|
||||||
|
|
||||||
推荐使用 ESEngine CLI 快速创建完整的游戏服务端项目:
|
你可以基于 `@esengine/rpc` 定义自己的协议:
|
||||||
|
|
||||||
```bash
|
```typescript
|
||||||
mkdir my-game-server && cd my-game-server
|
import { rpc } from '@esengine/rpc';
|
||||||
npm init -y
|
|
||||||
npx @esengine/cli init -p nodejs
|
|
||||||
```
|
|
||||||
|
|
||||||
生成的项目结构:
|
// 定义自定义协议
|
||||||
|
export const myProtocol = rpc.define({
|
||||||
|
api: {
|
||||||
|
login: rpc.api<{ username: string }, { token: string }>(),
|
||||||
|
getData: rpc.api<{ id: number }, { data: object }>(),
|
||||||
|
},
|
||||||
|
msg: {
|
||||||
|
chat: rpc.msg<{ from: string; text: string }>(),
|
||||||
|
notification: rpc.msg<{ type: string; content: string }>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
```
|
// 使用自定义协议创建服务
|
||||||
my-game-server/
|
import { RpcService } from '@esengine/network';
|
||||||
├── src/
|
|
||||||
│ ├── index.ts
|
const service = new RpcService(myProtocol);
|
||||||
│ ├── server/
|
await service.connect({ url: 'ws://localhost:3000' });
|
||||||
│ │ └── GameServer.ts
|
|
||||||
│ └── game/
|
// 类型安全的 API 调用
|
||||||
│ ├── Game.ts
|
const result = await service.call('login', { username: 'test' });
|
||||||
│ ├── scenes/
|
console.log(result.token);
|
||||||
│ ├── components/
|
|
||||||
│ └── systems/
|
// 类型安全的消息监听
|
||||||
├── tsconfig.json
|
service.on('chat', (data) => {
|
||||||
└── package.json
|
console.log(`${data.from}: ${data.text}`);
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## 文档导航
|
## 文档导航
|
||||||
|
|||||||
165
docs/src/content/docs/modules/rpc/index.md
Normal file
165
docs/src/content/docs/modules/rpc/index.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
---
|
||||||
|
title: "RPC 通信库"
|
||||||
|
description: "类型安全的 WebSocket RPC 通信库"
|
||||||
|
---
|
||||||
|
|
||||||
|
`@esengine/rpc` 是一个轻量级、类型安全的 RPC(远程过程调用)库,专为游戏和实时应用设计。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- **类型安全**:完整的 TypeScript 类型推断,API 调用和消息处理都有类型检查
|
||||||
|
- **双向通信**:支持请求-响应(API)和发布-订阅(消息)两种模式
|
||||||
|
- **编解码灵活**:内置 JSON 和 MessagePack 编解码器
|
||||||
|
- **跨平台**:支持浏览器、Node.js 和微信小游戏
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/rpc
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 定义协议
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { rpc } from '@esengine/rpc';
|
||||||
|
|
||||||
|
// 定义协议(共享于客户端和服务器)
|
||||||
|
export const chatProtocol = rpc.define({
|
||||||
|
api: {
|
||||||
|
// 请求-响应 API
|
||||||
|
login: rpc.api<{ username: string }, { userId: string; token: string }>(),
|
||||||
|
sendMessage: rpc.api<{ text: string }, { messageId: string }>(),
|
||||||
|
},
|
||||||
|
msg: {
|
||||||
|
// 单向消息(发布-订阅)
|
||||||
|
newMessage: rpc.msg<{ from: string; text: string; time: number }>(),
|
||||||
|
userJoined: rpc.msg<{ username: string }>(),
|
||||||
|
userLeft: rpc.msg<{ username: string }>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ChatProtocol = typeof chatProtocol;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 创建客户端
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { RpcClient } from '@esengine/rpc/client';
|
||||||
|
import { chatProtocol } from './protocol';
|
||||||
|
|
||||||
|
// 创建客户端
|
||||||
|
const client = new RpcClient(chatProtocol, 'ws://localhost:3000', {
|
||||||
|
onConnect: () => console.log('已连接'),
|
||||||
|
onDisconnect: (reason) => console.log('已断开:', reason),
|
||||||
|
onError: (error) => console.error('错误:', error),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 连接服务器
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// 调用 API(类型安全)
|
||||||
|
const { userId, token } = await client.call('login', { username: 'player1' });
|
||||||
|
console.log('登录成功:', userId);
|
||||||
|
|
||||||
|
// 监听消息(类型安全)
|
||||||
|
client.on('newMessage', (data) => {
|
||||||
|
console.log(`${data.from}: ${data.text}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
await client.call('sendMessage', { text: 'Hello!' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 创建服务器
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { RpcServer } from '@esengine/rpc/server';
|
||||||
|
import { chatProtocol } from './protocol';
|
||||||
|
|
||||||
|
// 创建服务器
|
||||||
|
const server = new RpcServer(chatProtocol, {
|
||||||
|
port: 3000,
|
||||||
|
onStart: (port) => console.log(`服务器启动: ws://localhost:${port}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册 API 处理器
|
||||||
|
server.handle('login', async (input, ctx) => {
|
||||||
|
const userId = generateUserId();
|
||||||
|
const token = generateToken();
|
||||||
|
|
||||||
|
// 广播用户加入
|
||||||
|
server.broadcast('userJoined', { username: input.username });
|
||||||
|
|
||||||
|
return { userId, token };
|
||||||
|
});
|
||||||
|
|
||||||
|
server.handle('sendMessage', async (input, ctx) => {
|
||||||
|
const messageId = generateMessageId();
|
||||||
|
|
||||||
|
// 广播新消息
|
||||||
|
server.broadcast('newMessage', {
|
||||||
|
from: ctx.clientId,
|
||||||
|
text: input.text,
|
||||||
|
time: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { messageId };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
await server.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心概念
|
||||||
|
|
||||||
|
### 协议定义
|
||||||
|
|
||||||
|
协议是客户端和服务器之间通信的契约:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const protocol = rpc.define({
|
||||||
|
api: {
|
||||||
|
// API:请求-响应模式,客户端发起,服务器处理并返回结果
|
||||||
|
methodName: rpc.api<InputType, OutputType>(),
|
||||||
|
},
|
||||||
|
msg: {
|
||||||
|
// 消息:发布-订阅模式,任意一方发送,对方监听
|
||||||
|
messageName: rpc.msg<DataType>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### API vs 消息
|
||||||
|
|
||||||
|
| 特性 | API | 消息 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 模式 | 请求-响应 | 发布-订阅 |
|
||||||
|
| 返回值 | 有(Promise) | 无 |
|
||||||
|
| 确认 | 等待响应 | 发送即忘 |
|
||||||
|
| 用途 | 登录、查询、操作 | 实时通知、状态同步 |
|
||||||
|
|
||||||
|
## 编解码器
|
||||||
|
|
||||||
|
内置两种编解码器:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { json, msgpack } from '@esengine/rpc/codec';
|
||||||
|
|
||||||
|
// JSON(默认,可读性好)
|
||||||
|
const client = new RpcClient(protocol, url, {
|
||||||
|
codec: json(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// MessagePack(二进制,更高效)
|
||||||
|
const client = new RpcClient(protocol, url, {
|
||||||
|
codec: msgpack(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文档导航
|
||||||
|
|
||||||
|
- [客户端 API](/modules/rpc/client/) - RpcClient 详细 API
|
||||||
|
- [服务器 API](/modules/rpc/server/) - RpcServer 详细 API
|
||||||
|
- [编解码器](/modules/rpc/codec/) - 自定义编解码器
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"test": "turbo run test",
|
"test": "turbo run test",
|
||||||
"test:coverage": "turbo run test:coverage",
|
"test:coverage": "turbo run test:coverage",
|
||||||
"test:ci": "turbo run test:ci",
|
"test:ci": "turbo run test:ci",
|
||||||
"test:ci:framework": "turbo run test:ci --filter=@esengine/ecs-framework --filter=@esengine/ecs-framework-math --filter=@esengine/behavior-tree --filter=@esengine/blueprint --filter=@esengine/fsm --filter=@esengine/timer --filter=@esengine/spatial --filter=@esengine/procgen --filter=@esengine/pathfinding --filter=@esengine/network-protocols --filter=@esengine/network",
|
"test:ci:framework": "turbo run test:ci --filter=@esengine/ecs-framework --filter=@esengine/ecs-framework-math --filter=@esengine/behavior-tree --filter=@esengine/blueprint --filter=@esengine/fsm --filter=@esengine/timer --filter=@esengine/spatial --filter=@esengine/procgen --filter=@esengine/pathfinding --filter=@esengine/network-protocols --filter=@esengine/rpc --filter=@esengine/network",
|
||||||
"prepare:publish": "npm run build:npm && node scripts/pre-publish-check.cjs",
|
"prepare:publish": "npm run build:npm && node scripts/pre-publish-check.cjs",
|
||||||
"sync:versions": "node scripts/sync-versions.cjs",
|
"sync:versions": "node scripts/sync-versions.cjs",
|
||||||
"publish:all": "npm run prepare:publish && npm run publish:all:dist",
|
"publish:all": "npm run prepare:publish && npm run publish:all:dist",
|
||||||
@@ -68,9 +68,9 @@
|
|||||||
"format": "prettier --write \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
|
"format": "prettier --write \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
|
||||||
"format:check": "prettier --check \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
|
"format:check": "prettier --check \"packages/**/src/**/*.{ts,tsx,js,jsx}\"",
|
||||||
"type-check": "turbo run type-check",
|
"type-check": "turbo run type-check",
|
||||||
"type-check:framework": "turbo run type-check --filter=@esengine/ecs-framework --filter=@esengine/ecs-framework-math --filter=@esengine/behavior-tree --filter=@esengine/blueprint --filter=@esengine/fsm --filter=@esengine/timer --filter=@esengine/spatial --filter=@esengine/procgen --filter=@esengine/pathfinding --filter=@esengine/network-protocols --filter=@esengine/network",
|
"type-check:framework": "turbo run type-check --filter=@esengine/ecs-framework --filter=@esengine/ecs-framework-math --filter=@esengine/behavior-tree --filter=@esengine/blueprint --filter=@esengine/fsm --filter=@esengine/timer --filter=@esengine/spatial --filter=@esengine/procgen --filter=@esengine/pathfinding --filter=@esengine/network-protocols --filter=@esengine/rpc --filter=@esengine/network",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"lint:framework": "turbo run lint --filter=@esengine/ecs-framework --filter=@esengine/ecs-framework-math --filter=@esengine/behavior-tree --filter=@esengine/blueprint --filter=@esengine/fsm --filter=@esengine/timer --filter=@esengine/spatial --filter=@esengine/procgen --filter=@esengine/pathfinding --filter=@esengine/network-protocols --filter=@esengine/network",
|
"lint:framework": "turbo run lint --filter=@esengine/ecs-framework --filter=@esengine/ecs-framework-math --filter=@esengine/behavior-tree --filter=@esengine/blueprint --filter=@esengine/fsm --filter=@esengine/timer --filter=@esengine/spatial --filter=@esengine/procgen --filter=@esengine/pathfinding --filter=@esengine/network-protocols --filter=@esengine/rpc --filter=@esengine/network",
|
||||||
"lint:fix": "turbo run lint:fix",
|
"lint:fix": "turbo run lint:fix",
|
||||||
"build:wasm": "cd packages/rust/engine && wasm-pack build --dev --out-dir pkg",
|
"build:wasm": "cd packages/rust/engine && wasm-pack build --dev --out-dir pkg",
|
||||||
"build:wasm:release": "cd packages/rust/engine && wasm-pack build --release --out-dir pkg",
|
"build:wasm:release": "cd packages/rust/engine && wasm-pack build --release --out-dir pkg",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@esengine/network",
|
"name": "@esengine/network",
|
||||||
"displayName": "Network",
|
"displayName": "Network",
|
||||||
"description": "TSRPC-based network synchronization for multiplayer games",
|
"description": "RPC-based network synchronization for multiplayer games",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"category": "network",
|
"category": "network",
|
||||||
"dependencies": [],
|
"dependencies": ["@esengine/rpc"],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"name": "NetworkIdentity",
|
"name": "NetworkIdentity",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@esengine/network",
|
"name": "@esengine/network",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"description": "Network synchronization for multiplayer games based on TSRPC",
|
"description": "Network synchronization for multiplayer games",
|
||||||
"esengine": {
|
"esengine": {
|
||||||
"plugin": true,
|
"plugin": true,
|
||||||
"pluginExport": "NetworkPlugin",
|
"pluginExport": "NetworkPlugin",
|
||||||
@@ -22,14 +22,13 @@
|
|||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup && tsc --project tsconfig.build.json --declaration --emitDeclarationOnly --outDir dist",
|
"build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly",
|
||||||
"build:watch": "tsup --watch",
|
"build:watch": "tsup --watch",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"clean": "rimraf dist"
|
"clean": "rimraf dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@esengine/network-protocols": "workspace:*",
|
"@esengine/rpc": "workspace:*"
|
||||||
"tsrpc-browser": "^3.4.16"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@esengine/blueprint": "workspace:*",
|
"@esengine/blueprint": "workspace:*",
|
||||||
|
|||||||
@@ -1,153 +1,199 @@
|
|||||||
import { type IPlugin, Core, type ServiceContainer, type Scene } from '@esengine/ecs-framework';
|
/**
|
||||||
import { NetworkService } from './services/NetworkService';
|
* @zh 网络插件
|
||||||
import { NetworkSyncSystem } from './systems/NetworkSyncSystem';
|
* @en Network Plugin
|
||||||
import { NetworkSpawnSystem, type PrefabFactory } from './systems/NetworkSpawnSystem';
|
*/
|
||||||
import { NetworkInputSystem } from './systems/NetworkInputSystem';
|
|
||||||
|
import { type IPlugin, Core, type ServiceContainer, type Scene } from '@esengine/ecs-framework'
|
||||||
|
import { GameNetworkService, type NetworkServiceOptions } from './services/NetworkService'
|
||||||
|
import { NetworkSyncSystem } from './systems/NetworkSyncSystem'
|
||||||
|
import { NetworkSpawnSystem, type PrefabFactory } from './systems/NetworkSpawnSystem'
|
||||||
|
import { NetworkInputSystem } from './systems/NetworkInputSystem'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 网络插件
|
* @zh 网络插件
|
||||||
* Network plugin
|
* @en Network plugin
|
||||||
*
|
*
|
||||||
* 提供基于 TSRPC 的网络同步功能。
|
* @zh 提供基于 @esengine/rpc 的网络同步功能
|
||||||
* Provides TSRPC-based network synchronization.
|
* @en Provides @esengine/rpc based network synchronization
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* import { Core } from '@esengine/ecs-framework';
|
* import { Core } from '@esengine/ecs-framework'
|
||||||
* import { NetworkPlugin } from '@esengine/network';
|
* import { NetworkPlugin } from '@esengine/network'
|
||||||
*
|
*
|
||||||
* const networkPlugin = new NetworkPlugin();
|
* const networkPlugin = new NetworkPlugin()
|
||||||
* await Core.installPlugin(networkPlugin);
|
* await Core.installPlugin(networkPlugin)
|
||||||
*
|
*
|
||||||
* // 连接到服务器 | Connect to server
|
* // 连接到服务器
|
||||||
* await networkPlugin.connect('ws://localhost:3000', 'Player1');
|
* await networkPlugin.connect({ url: 'ws://localhost:3000', playerName: 'Player1' })
|
||||||
*
|
*
|
||||||
* // 注册预制体 | Register prefab
|
* // 注册预制体
|
||||||
* networkPlugin.registerPrefab('player', (scene, spawn) => {
|
* networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||||
* const entity = scene.createEntity('Player');
|
* const entity = scene.createEntity('Player')
|
||||||
* return entity;
|
* return entity
|
||||||
* });
|
* })
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class NetworkPlugin implements IPlugin {
|
export class NetworkPlugin implements IPlugin {
|
||||||
public readonly name = '@esengine/network';
|
public readonly name = '@esengine/network'
|
||||||
public readonly version = '1.0.0';
|
public readonly version = '2.0.0'
|
||||||
|
|
||||||
private _networkService!: NetworkService;
|
private _networkService!: GameNetworkService
|
||||||
private _syncSystem!: NetworkSyncSystem;
|
private _syncSystem!: NetworkSyncSystem
|
||||||
private _spawnSystem!: NetworkSpawnSystem;
|
private _spawnSystem!: NetworkSpawnSystem
|
||||||
private _inputSystem!: NetworkInputSystem;
|
private _inputSystem!: NetworkInputSystem
|
||||||
|
private _localPlayerId: number = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 网络服务
|
* @zh 网络服务
|
||||||
* Network service
|
* @en Network service
|
||||||
*/
|
*/
|
||||||
get networkService(): NetworkService {
|
get networkService(): GameNetworkService {
|
||||||
return this._networkService;
|
return this._networkService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步系统
|
* @zh 同步系统
|
||||||
* Sync system
|
* @en Sync system
|
||||||
*/
|
*/
|
||||||
get syncSystem(): NetworkSyncSystem {
|
get syncSystem(): NetworkSyncSystem {
|
||||||
return this._syncSystem;
|
return this._syncSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成系统
|
* @zh 生成系统
|
||||||
* Spawn system
|
* @en Spawn system
|
||||||
*/
|
*/
|
||||||
get spawnSystem(): NetworkSpawnSystem {
|
get spawnSystem(): NetworkSpawnSystem {
|
||||||
return this._spawnSystem;
|
return this._spawnSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 输入系统
|
* @zh 输入系统
|
||||||
* Input system
|
* @en Input system
|
||||||
*/
|
*/
|
||||||
get inputSystem(): NetworkInputSystem {
|
get inputSystem(): NetworkInputSystem {
|
||||||
return this._inputSystem;
|
return this._inputSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否已连接
|
* @zh 本地玩家 ID
|
||||||
* Is connected
|
* @en Local player ID
|
||||||
|
*/
|
||||||
|
get localPlayerId(): number {
|
||||||
|
return this._localPlayerId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否已连接
|
||||||
|
* @en Is connected
|
||||||
*/
|
*/
|
||||||
get isConnected(): boolean {
|
get isConnected(): boolean {
|
||||||
return this._networkService?.isConnected ?? false;
|
return this._networkService?.isConnected ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 安装插件
|
* @zh 安装插件
|
||||||
* Install plugin
|
* @en Install plugin
|
||||||
*/
|
*/
|
||||||
install(_core: Core, _services: ServiceContainer): void {
|
install(_core: Core, _services: ServiceContainer): void {
|
||||||
this._networkService = new NetworkService();
|
this._networkService = new GameNetworkService()
|
||||||
|
|
||||||
// 当场景加载时添加系统
|
const scene = Core.scene
|
||||||
// Add systems when scene loads
|
|
||||||
const scene = Core.scene;
|
|
||||||
if (scene) {
|
if (scene) {
|
||||||
this._setupSystems(scene as Scene);
|
this._setupSystems(scene as Scene)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 卸载插件
|
* @zh 卸载插件
|
||||||
* Uninstall plugin
|
* @en Uninstall plugin
|
||||||
*/
|
*/
|
||||||
uninstall(): void {
|
uninstall(): void {
|
||||||
this._networkService?.disconnect();
|
this._networkService?.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private _setupSystems(scene: Scene): void {
|
private _setupSystems(scene: Scene): void {
|
||||||
this._syncSystem = new NetworkSyncSystem(this._networkService);
|
this._syncSystem = new NetworkSyncSystem()
|
||||||
this._spawnSystem = new NetworkSpawnSystem(this._networkService, this._syncSystem);
|
this._spawnSystem = new NetworkSpawnSystem(this._syncSystem)
|
||||||
this._inputSystem = new NetworkInputSystem(this._networkService);
|
this._inputSystem = new NetworkInputSystem(this._networkService)
|
||||||
|
|
||||||
scene.addSystem(this._syncSystem);
|
scene.addSystem(this._syncSystem)
|
||||||
scene.addSystem(this._spawnSystem);
|
scene.addSystem(this._spawnSystem)
|
||||||
scene.addSystem(this._inputSystem);
|
scene.addSystem(this._inputSystem)
|
||||||
|
|
||||||
|
this._setupMessageHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setupMessageHandlers(): void {
|
||||||
|
this._networkService
|
||||||
|
.onSync((data) => {
|
||||||
|
this._syncSystem.handleSync({ entities: data.entities })
|
||||||
|
})
|
||||||
|
.onSpawn((data) => {
|
||||||
|
this._spawnSystem.handleSpawn(data)
|
||||||
|
})
|
||||||
|
.onDespawn((data) => {
|
||||||
|
this._spawnSystem.handleDespawn(data)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 连接到服务器
|
* @zh 连接到服务器
|
||||||
* Connect to server
|
* @en Connect to server
|
||||||
*/
|
*/
|
||||||
public async connect(serverUrl: string, playerName: string, roomId?: string): Promise<boolean> {
|
public async connect(options: NetworkServiceOptions & { playerName: string; roomId?: string }): Promise<boolean> {
|
||||||
return this._networkService.connect(serverUrl, playerName, roomId);
|
try {
|
||||||
|
await this._networkService.connect(options)
|
||||||
|
|
||||||
|
const result = await this._networkService.call('join', {
|
||||||
|
playerName: options.playerName,
|
||||||
|
roomId: options.roomId,
|
||||||
|
})
|
||||||
|
|
||||||
|
this._localPlayerId = result.playerId
|
||||||
|
this._spawnSystem.setLocalPlayerId(this._localPlayerId)
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 断开连接
|
* @zh 断开连接
|
||||||
* Disconnect
|
* @en Disconnect
|
||||||
*/
|
*/
|
||||||
public async disconnect(): Promise<void> {
|
public async disconnect(): Promise<void> {
|
||||||
await this._networkService.disconnect();
|
try {
|
||||||
|
await this._networkService.call('leave', undefined)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
this._networkService.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册预制体工厂
|
* @zh 注册预制体工厂
|
||||||
* Register prefab factory
|
* @en Register prefab factory
|
||||||
*/
|
*/
|
||||||
public registerPrefab(prefabType: string, factory: PrefabFactory): void {
|
public registerPrefab(prefabType: string, factory: PrefabFactory): void {
|
||||||
this._spawnSystem?.registerPrefab(prefabType, factory);
|
this._spawnSystem?.registerPrefab(prefabType, factory)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送移动输入
|
* @zh 发送移动输入
|
||||||
* Send move input
|
* @en Send move input
|
||||||
*/
|
*/
|
||||||
public sendMoveInput(x: number, y: number): void {
|
public sendMoveInput(x: number, y: number): void {
|
||||||
this._inputSystem?.addMoveInput(x, y);
|
this._inputSystem?.addMoveInput(x, y)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送动作输入
|
* @zh 发送动作输入
|
||||||
* Send action input
|
* @en Send action input
|
||||||
*/
|
*/
|
||||||
public sendActionInput(action: string): void {
|
public sendActionInput(action: string): void {
|
||||||
this._inputSystem?.addActionInput(action);
|
this._inputSystem?.addActionInput(action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,43 @@
|
|||||||
/**
|
/**
|
||||||
* @esengine/network
|
* @zh @esengine/network 网络同步模块
|
||||||
|
* @en @esengine/network Network synchronization module
|
||||||
*
|
*
|
||||||
* 基于 TSRPC 的网络同步模块(客户端)
|
* @zh 基于 @esengine/rpc 的网络同步模块,提供类型安全的多人游戏网络通信
|
||||||
* TSRPC-based network synchronization module (client)
|
* @en Network synchronization module based on @esengine/rpc for type-safe multiplayer game communication
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Re-export from protocols | 从协议包重新导出
|
// Re-export from RPC | 从 RPC 包重新导出
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
export { rpc } from '@esengine/rpc'
|
||||||
export type {
|
export type {
|
||||||
ServiceType,
|
ProtocolDef,
|
||||||
Vec2,
|
ApiDef,
|
||||||
IEntityState,
|
MsgDef,
|
||||||
IPlayerInput,
|
ApiInput,
|
||||||
MsgSync,
|
ApiOutput,
|
||||||
MsgInput,
|
MsgData,
|
||||||
MsgSpawn,
|
ApiNames,
|
||||||
MsgDespawn,
|
MsgNames,
|
||||||
ReqJoin,
|
RpcError,
|
||||||
ResJoin
|
} from '@esengine/rpc'
|
||||||
} from '@esengine/network-protocols';
|
|
||||||
|
|
||||||
export { serviceProto } from '@esengine/network-protocols';
|
// ============================================================================
|
||||||
|
// Protocol | 协议
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
gameProtocol,
|
||||||
|
type GameProtocol,
|
||||||
|
type PlayerInput,
|
||||||
|
type EntitySyncState,
|
||||||
|
type SyncData,
|
||||||
|
type SpawnData,
|
||||||
|
type DespawnData,
|
||||||
|
type JoinRequest,
|
||||||
|
type JoinResponse,
|
||||||
|
} from './protocol'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Tokens | 服务令牌
|
// Tokens | 服务令牌
|
||||||
@@ -32,37 +47,44 @@ export {
|
|||||||
NetworkServiceToken,
|
NetworkServiceToken,
|
||||||
NetworkSyncSystemToken,
|
NetworkSyncSystemToken,
|
||||||
NetworkSpawnSystemToken,
|
NetworkSpawnSystemToken,
|
||||||
NetworkInputSystemToken
|
NetworkInputSystemToken,
|
||||||
} from './tokens';
|
} from './tokens'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Plugin | 插件
|
// Plugin | 插件
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export { NetworkPlugin } from './NetworkPlugin';
|
export { NetworkPlugin } from './NetworkPlugin'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Services | 服务
|
// Services | 服务
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export { NetworkService, ENetworkState } from './services/NetworkService';
|
export {
|
||||||
export type { INetworkCallbacks } from './services/NetworkService';
|
RpcService,
|
||||||
|
GameNetworkService,
|
||||||
|
NetworkService,
|
||||||
|
NetworkState,
|
||||||
|
createNetworkService,
|
||||||
|
} from './services/NetworkService'
|
||||||
|
export type { NetworkServiceOptions } from './services/NetworkService'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Components | 组件
|
// Components | 组件
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export { NetworkIdentity } from './components/NetworkIdentity';
|
export { NetworkIdentity } from './components/NetworkIdentity'
|
||||||
export { NetworkTransform } from './components/NetworkTransform';
|
export { NetworkTransform } from './components/NetworkTransform'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Systems | 系统
|
// Systems | 系统
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export { NetworkSyncSystem } from './systems/NetworkSyncSystem';
|
export { NetworkSyncSystem } from './systems/NetworkSyncSystem'
|
||||||
export { NetworkSpawnSystem } from './systems/NetworkSpawnSystem';
|
export type { SyncMessage } from './systems/NetworkSyncSystem'
|
||||||
export type { PrefabFactory } from './systems/NetworkSpawnSystem';
|
export { NetworkSpawnSystem } from './systems/NetworkSpawnSystem'
|
||||||
export { NetworkInputSystem } from './systems/NetworkInputSystem';
|
export type { PrefabFactory, SpawnMessage, DespawnMessage } from './systems/NetworkSpawnSystem'
|
||||||
|
export { NetworkInputSystem } from './systems/NetworkInputSystem'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// State Sync | 状态同步
|
// State Sync | 状态同步
|
||||||
@@ -73,8 +95,8 @@ export type {
|
|||||||
ITransformState,
|
ITransformState,
|
||||||
ITransformStateWithVelocity,
|
ITransformStateWithVelocity,
|
||||||
ISnapshotBufferConfig,
|
ISnapshotBufferConfig,
|
||||||
ISnapshotBuffer
|
ISnapshotBuffer,
|
||||||
} from './sync';
|
} from './sync'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
IInterpolator,
|
IInterpolator,
|
||||||
@@ -82,8 +104,8 @@ export type {
|
|||||||
IInputSnapshot,
|
IInputSnapshot,
|
||||||
IPredictedState,
|
IPredictedState,
|
||||||
IPredictor,
|
IPredictor,
|
||||||
ClientPredictionConfig
|
ClientPredictionConfig,
|
||||||
} from './sync';
|
} from './sync'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
lerp,
|
lerp,
|
||||||
@@ -96,8 +118,8 @@ export {
|
|||||||
createTransformInterpolator,
|
createTransformInterpolator,
|
||||||
createHermiteTransformInterpolator,
|
createHermiteTransformInterpolator,
|
||||||
ClientPrediction,
|
ClientPrediction,
|
||||||
createClientPrediction
|
createClientPrediction,
|
||||||
} from './sync';
|
} from './sync'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Blueprint Nodes | 蓝图节点
|
// Blueprint Nodes | 蓝图节点
|
||||||
@@ -114,5 +136,5 @@ export {
|
|||||||
HasAuthorityExecutor,
|
HasAuthorityExecutor,
|
||||||
GetNetworkIdExecutor,
|
GetNetworkIdExecutor,
|
||||||
GetLocalPlayerIdExecutor,
|
GetLocalPlayerIdExecutor,
|
||||||
NetworkNodeDefinitions
|
NetworkNodeDefinitions,
|
||||||
} from './nodes';
|
} from './nodes'
|
||||||
|
|||||||
180
packages/framework/network/src/protocol.ts
Normal file
180
packages/framework/network/src/protocol.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* @zh 游戏网络协议定义
|
||||||
|
* @en Game Network Protocol Definition
|
||||||
|
*
|
||||||
|
* @zh 定义客户端与服务器之间的通信协议
|
||||||
|
* @en Defines the communication protocol between client and server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { rpc } from '@esengine/rpc'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Message Types | 消息类型
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 玩家输入
|
||||||
|
* @en Player input
|
||||||
|
*/
|
||||||
|
export interface PlayerInput {
|
||||||
|
/**
|
||||||
|
* @zh 帧序号
|
||||||
|
* @en Frame number
|
||||||
|
*/
|
||||||
|
frame: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 移动方向
|
||||||
|
* @en Move direction
|
||||||
|
*/
|
||||||
|
moveDir?: { x: number; y: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 动作列表
|
||||||
|
* @en Action list
|
||||||
|
*/
|
||||||
|
actions?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 实体同步状态
|
||||||
|
* @en Entity sync state
|
||||||
|
*/
|
||||||
|
export interface EntitySyncState {
|
||||||
|
netId: number
|
||||||
|
pos?: { x: number; y: number }
|
||||||
|
rot?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 同步消息
|
||||||
|
* @en Sync message
|
||||||
|
*/
|
||||||
|
export interface SyncData {
|
||||||
|
/**
|
||||||
|
* @zh 服务器帧号
|
||||||
|
* @en Server frame number
|
||||||
|
*/
|
||||||
|
frame: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 实体状态列表
|
||||||
|
* @en Entity state list
|
||||||
|
*/
|
||||||
|
entities: EntitySyncState[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 生成消息
|
||||||
|
* @en Spawn message
|
||||||
|
*/
|
||||||
|
export interface SpawnData {
|
||||||
|
netId: number
|
||||||
|
ownerId: number
|
||||||
|
prefab: string
|
||||||
|
pos: { x: number; y: number }
|
||||||
|
rot?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 销毁消息
|
||||||
|
* @en Despawn message
|
||||||
|
*/
|
||||||
|
export interface DespawnData {
|
||||||
|
netId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API Types | API 类型
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 加入房间请求
|
||||||
|
* @en Join room request
|
||||||
|
*/
|
||||||
|
export interface JoinRequest {
|
||||||
|
playerName: string
|
||||||
|
roomId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 加入房间响应
|
||||||
|
* @en Join room response
|
||||||
|
*/
|
||||||
|
export interface JoinResponse {
|
||||||
|
playerId: number
|
||||||
|
roomId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Protocol Definition | 协议定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 默认游戏网络协议
|
||||||
|
* @en Default game network protocol
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // 使用默认协议
|
||||||
|
* const service = new NetworkService(gameProtocol)
|
||||||
|
*
|
||||||
|
* // 或者扩展协议
|
||||||
|
* const customProtocol = rpc.define({
|
||||||
|
* api: {
|
||||||
|
* ...gameProtocol.api,
|
||||||
|
* customApi: rpc.api<CustomInput, CustomOutput>(),
|
||||||
|
* },
|
||||||
|
* msg: {
|
||||||
|
* ...gameProtocol.msg,
|
||||||
|
* customMsg: rpc.msg<CustomData>(),
|
||||||
|
* },
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const gameProtocol = rpc.define({
|
||||||
|
api: {
|
||||||
|
/**
|
||||||
|
* @zh 加入房间
|
||||||
|
* @en Join room
|
||||||
|
*/
|
||||||
|
join: rpc.api<JoinRequest, JoinResponse>(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 离开房间
|
||||||
|
* @en Leave room
|
||||||
|
*/
|
||||||
|
leave: rpc.api<void, void>(),
|
||||||
|
},
|
||||||
|
msg: {
|
||||||
|
/**
|
||||||
|
* @zh 玩家输入
|
||||||
|
* @en Player input
|
||||||
|
*/
|
||||||
|
input: rpc.msg<PlayerInput>(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 状态同步
|
||||||
|
* @en State sync
|
||||||
|
*/
|
||||||
|
sync: rpc.msg<SyncData>(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 实体生成
|
||||||
|
* @en Entity spawn
|
||||||
|
*/
|
||||||
|
spawn: rpc.msg<SpawnData>(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 实体销毁
|
||||||
|
* @en Entity despawn
|
||||||
|
*/
|
||||||
|
despawn: rpc.msg<DespawnData>(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 游戏协议类型
|
||||||
|
* @en Game protocol type
|
||||||
|
*/
|
||||||
|
export type GameProtocol = typeof gameProtocol
|
||||||
@@ -1,172 +1,274 @@
|
|||||||
import { WsClient } from 'tsrpc-browser';
|
/**
|
||||||
|
* @zh 网络服务模块
|
||||||
|
* @en Network Service Module
|
||||||
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
serviceProto,
|
RpcClient,
|
||||||
type ServiceType,
|
type ProtocolDef,
|
||||||
type MsgSync,
|
type ApiNames,
|
||||||
type MsgSpawn,
|
type MsgNames,
|
||||||
type MsgDespawn,
|
type ApiInput,
|
||||||
type IPlayerInput
|
type ApiOutput,
|
||||||
} from '@esengine/network-protocols';
|
type MsgData,
|
||||||
|
type RpcClientOptions,
|
||||||
|
} from '@esengine/rpc/client'
|
||||||
|
import { gameProtocol, type GameProtocol, type PlayerInput } from '../protocol'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types | 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 连接状态
|
* @zh 连接状态
|
||||||
* Connection state
|
* @en Connection state
|
||||||
*/
|
*/
|
||||||
export const enum ENetworkState {
|
export const enum NetworkState {
|
||||||
Disconnected = 0,
|
Disconnected = 0,
|
||||||
Connecting = 1,
|
Connecting = 1,
|
||||||
Connected = 2
|
Connected = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 网络事件回调
|
* @zh 网络服务配置
|
||||||
* Network event callbacks
|
* @en Network service options
|
||||||
*/
|
*/
|
||||||
export interface INetworkCallbacks {
|
export interface NetworkServiceOptions extends RpcClientOptions {
|
||||||
onConnected?: (clientId: number, roomId: string) => void;
|
/**
|
||||||
onDisconnected?: () => void;
|
* @zh 服务器地址
|
||||||
onSync?: (msg: MsgSync) => void;
|
* @en Server URL
|
||||||
onSpawn?: (msg: MsgSpawn) => void;
|
|
||||||
onDespawn?: (msg: MsgDespawn) => void;
|
|
||||||
onError?: (error: Error) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建 TSRPC 客户端
|
|
||||||
* Create TSRPC client
|
|
||||||
*/
|
*/
|
||||||
function createClient(serverUrl: string): WsClient<ServiceType> {
|
url: string
|
||||||
return new WsClient(serviceProto, {
|
|
||||||
server: serverUrl,
|
|
||||||
json: true,
|
|
||||||
logLevel: 'warn'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RpcService - Base Class | RPC 服务基类
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 网络服务
|
* @zh RPC 服务基类
|
||||||
* Network service
|
* @en RPC Service base class
|
||||||
*
|
*
|
||||||
* 基于 TSRPC 的网络服务封装,提供类型安全的网络通信。
|
* @zh 纯粹的 RPC 客户端封装,不包含任何游戏特定逻辑
|
||||||
* TSRPC-based network service wrapper with type-safe communication.
|
* @en Pure RPC client wrapper without any game-specific logic
|
||||||
|
*
|
||||||
|
* @typeParam P - @zh 协议定义类型 @en Protocol definition type
|
||||||
*/
|
*/
|
||||||
export class NetworkService {
|
export class RpcService<P extends ProtocolDef> {
|
||||||
private _client: WsClient<ServiceType> | null = null;
|
protected _client: RpcClient<P> | null = null
|
||||||
private _state: ENetworkState = ENetworkState.Disconnected;
|
protected _state: NetworkState = NetworkState.Disconnected
|
||||||
private _clientId: number = 0;
|
|
||||||
private _roomId: string = '';
|
|
||||||
private _callbacks: INetworkCallbacks = {};
|
|
||||||
|
|
||||||
get state(): ENetworkState {
|
constructor(protected readonly _protocol: P) {}
|
||||||
return this._state;
|
|
||||||
}
|
/**
|
||||||
|
* @zh 获取连接状态
|
||||||
get clientId(): number {
|
* @en Get connection state
|
||||||
return this._clientId;
|
*/
|
||||||
}
|
get state(): NetworkState {
|
||||||
|
return this._state
|
||||||
get roomId(): string {
|
|
||||||
return this._roomId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否已连接
|
||||||
|
* @en Whether connected
|
||||||
|
*/
|
||||||
get isConnected(): boolean {
|
get isConnected(): boolean {
|
||||||
return this._state === ENetworkState.Connected;
|
return this._state === NetworkState.Connected
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置回调
|
* @zh 获取底层 RPC 客户端
|
||||||
* Set callbacks
|
* @en Get underlying RPC client
|
||||||
*/
|
*/
|
||||||
setCallbacks(callbacks: INetworkCallbacks): void {
|
get client(): RpcClient<P> | null {
|
||||||
this._callbacks = { ...this._callbacks, ...callbacks };
|
return this._client
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 连接到服务器
|
* @zh 连接到服务器
|
||||||
* Connect to server
|
* @en Connect to server
|
||||||
*/
|
*/
|
||||||
async connect(serverUrl: string, playerName: string, roomId?: string): Promise<boolean> {
|
async connect(options: NetworkServiceOptions): Promise<void> {
|
||||||
if (this._state !== ENetworkState.Disconnected) {
|
if (this._state !== NetworkState.Disconnected) {
|
||||||
return false;
|
throw new Error('Already connected or connecting')
|
||||||
}
|
}
|
||||||
|
|
||||||
this._state = ENetworkState.Connecting;
|
this._state = NetworkState.Connecting
|
||||||
this._client = createClient(serverUrl);
|
|
||||||
this._setupListeners();
|
|
||||||
|
|
||||||
// 连接
|
try {
|
||||||
// Connect
|
this._client = new RpcClient(this._protocol, options.url, {
|
||||||
const connectResult = await this._client.connect();
|
...options,
|
||||||
if (!connectResult.isSucc) {
|
onConnect: () => {
|
||||||
this._state = ENetworkState.Disconnected;
|
this._state = NetworkState.Connected
|
||||||
this._callbacks.onError?.(new Error(connectResult.errMsg));
|
options.onConnect?.()
|
||||||
return false;
|
},
|
||||||
|
onDisconnect: (reason) => {
|
||||||
|
this._state = NetworkState.Disconnected
|
||||||
|
options.onDisconnect?.(reason)
|
||||||
|
},
|
||||||
|
onError: options.onError,
|
||||||
|
})
|
||||||
|
await this._client.connect()
|
||||||
|
this._state = NetworkState.Connected
|
||||||
|
} catch (err) {
|
||||||
|
this._state = NetworkState.Disconnected
|
||||||
|
this._client = null
|
||||||
|
throw err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加入房间
|
|
||||||
// Join room
|
|
||||||
const joinResult = await this._client.callApi('Join', {
|
|
||||||
playerName,
|
|
||||||
roomId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!joinResult.isSucc) {
|
|
||||||
await this._client.disconnect();
|
|
||||||
this._state = ENetworkState.Disconnected;
|
|
||||||
this._callbacks.onError?.(new Error(joinResult.err.message));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._clientId = joinResult.res.clientId;
|
|
||||||
this._roomId = joinResult.res.roomId;
|
|
||||||
this._state = ENetworkState.Connected;
|
|
||||||
this._callbacks.onConnected?.(this._clientId, this._roomId);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 断开连接
|
* @zh 断开连接
|
||||||
* Disconnect
|
* @en Disconnect
|
||||||
*/
|
*/
|
||||||
async disconnect(): Promise<void> {
|
disconnect(): void {
|
||||||
if (this._client) {
|
this._client?.disconnect()
|
||||||
await this._client.disconnect();
|
this._client = null
|
||||||
}
|
this._state = NetworkState.Disconnected
|
||||||
this._state = ENetworkState.Disconnected;
|
|
||||||
this._clientId = 0;
|
|
||||||
this._roomId = '';
|
|
||||||
this._client = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送输入
|
* @zh 调用 API
|
||||||
* Send input
|
* @en Call API
|
||||||
*/
|
*/
|
||||||
sendInput(input: IPlayerInput): void {
|
call<K extends ApiNames<P>>(
|
||||||
if (!this.isConnected || !this._client) return;
|
name: K,
|
||||||
this._client.sendMsg('Input', { input });
|
input: ApiInput<P['api'][K]>
|
||||||
|
): Promise<ApiOutput<P['api'][K]>> {
|
||||||
|
if (!this._client) {
|
||||||
|
return Promise.reject(new Error('Not connected'))
|
||||||
|
}
|
||||||
|
return this._client.call(name, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
private _setupListeners(): void {
|
/**
|
||||||
if (!this._client) return;
|
* @zh 发送消息
|
||||||
|
* @en Send message
|
||||||
|
*/
|
||||||
|
send<K extends MsgNames<P>>(name: K, data: MsgData<P['msg'][K]>): void {
|
||||||
|
this._client?.send(name, data)
|
||||||
|
}
|
||||||
|
|
||||||
this._client.listenMsg('Sync', (msg) => {
|
/**
|
||||||
this._callbacks.onSync?.(msg);
|
* @zh 监听消息
|
||||||
});
|
* @en Listen to message
|
||||||
|
*/
|
||||||
|
on<K extends MsgNames<P>>(
|
||||||
|
name: K,
|
||||||
|
handler: (data: MsgData<P['msg'][K]>) => void
|
||||||
|
): this {
|
||||||
|
this._client?.on(name, handler)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
this._client.listenMsg('Spawn', (msg) => {
|
/**
|
||||||
this._callbacks.onSpawn?.(msg);
|
* @zh 取消监听消息
|
||||||
});
|
* @en Remove message listener
|
||||||
|
*/
|
||||||
|
off<K extends MsgNames<P>>(
|
||||||
|
name: K,
|
||||||
|
handler?: (data: MsgData<P['msg'][K]>) => void
|
||||||
|
): this {
|
||||||
|
this._client?.off(name, handler)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
this._client.listenMsg('Despawn', (msg) => {
|
/**
|
||||||
this._callbacks.onDespawn?.(msg);
|
* @zh 监听消息(只触发一次)
|
||||||
});
|
* @en Listen to message (once)
|
||||||
|
*/
|
||||||
this._client.flows.postDisconnectFlow.push((v) => {
|
once<K extends MsgNames<P>>(
|
||||||
this._state = ENetworkState.Disconnected;
|
name: K,
|
||||||
this._callbacks.onDisconnected?.();
|
handler: (data: MsgData<P['msg'][K]>) => void
|
||||||
return v;
|
): this {
|
||||||
});
|
this._client?.once(name, handler)
|
||||||
|
return this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GameNetworkService - Game-specific Class | 游戏网络服务
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 游戏网络服务
|
||||||
|
* @en Game network service
|
||||||
|
*
|
||||||
|
* @zh 基于默认游戏协议的网络服务,提供游戏特定的便捷方法
|
||||||
|
* @en Network service based on default game protocol with game-specific convenience methods
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const network = new GameNetworkService()
|
||||||
|
* await network.connect({ url: 'ws://localhost:3000' })
|
||||||
|
*
|
||||||
|
* // 游戏特定的便捷方法
|
||||||
|
* network.sendInput({ frame: 1, moveDir: { x: 1, y: 0 } })
|
||||||
|
*
|
||||||
|
* network.onSync((data) => {
|
||||||
|
* for (const entity of data.entities) {
|
||||||
|
* // 更新实体状态
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class GameNetworkService extends RpcService<GameProtocol> {
|
||||||
|
constructor() {
|
||||||
|
super(gameProtocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 发送玩家输入
|
||||||
|
* @en Send player input
|
||||||
|
*/
|
||||||
|
sendInput(input: PlayerInput): void {
|
||||||
|
this.send('input', input)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 监听状态同步
|
||||||
|
* @en Listen to state sync
|
||||||
|
*/
|
||||||
|
onSync(handler: (data: MsgData<GameProtocol['msg']['sync']>) => void): this {
|
||||||
|
return this.on('sync', handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 监听实体生成
|
||||||
|
* @en Listen to entity spawn
|
||||||
|
*/
|
||||||
|
onSpawn(handler: (data: MsgData<GameProtocol['msg']['spawn']>) => void): this {
|
||||||
|
return this.on('spawn', handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 监听实体销毁
|
||||||
|
* @en Listen to entity despawn
|
||||||
|
*/
|
||||||
|
onDespawn(handler: (data: MsgData<GameProtocol['msg']['despawn']>) => void): this {
|
||||||
|
return this.on('despawn', handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Exports & Factories | 导出与工厂函数
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 网络服务(GameNetworkService 的别名)
|
||||||
|
* @en Network service (alias for GameNetworkService)
|
||||||
|
*/
|
||||||
|
export { GameNetworkService as NetworkService }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建网络服务
|
||||||
|
* @en Create network service
|
||||||
|
*/
|
||||||
|
export function createNetworkService(): GameNetworkService
|
||||||
|
export function createNetworkService<P extends ProtocolDef>(protocol: P): RpcService<P>
|
||||||
|
export function createNetworkService<P extends ProtocolDef>(protocol?: P): RpcService<P> | GameNetworkService {
|
||||||
|
if (protocol) {
|
||||||
|
return new RpcService(protocol)
|
||||||
|
}
|
||||||
|
return new GameNetworkService()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,73 +1,74 @@
|
|||||||
import { EntitySystem, Matcher } from '@esengine/ecs-framework';
|
/**
|
||||||
import type { IPlayerInput } from '@esengine/network-protocols';
|
* @zh 网络输入系统
|
||||||
import type { NetworkService } from '../services/NetworkService';
|
* @en Network Input System
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EntitySystem, Matcher } from '@esengine/ecs-framework'
|
||||||
|
import type { PlayerInput } from '../protocol'
|
||||||
|
import type { NetworkService } from '../services/NetworkService'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 网络输入系统
|
* @zh 网络输入系统
|
||||||
* Network input system
|
* @en Network input system
|
||||||
*
|
*
|
||||||
* 收集本地玩家输入并发送到服务器。
|
* @zh 收集本地玩家输入并发送到服务器
|
||||||
* Collects local player input and sends to server.
|
* @en Collects local player input and sends to server
|
||||||
*/
|
*/
|
||||||
export class NetworkInputSystem extends EntitySystem {
|
export class NetworkInputSystem extends EntitySystem {
|
||||||
private _networkService: NetworkService;
|
private _networkService: NetworkService
|
||||||
private _frame: number = 0;
|
private _frame: number = 0
|
||||||
private _inputQueue: IPlayerInput[] = [];
|
private _inputQueue: PlayerInput[] = []
|
||||||
|
|
||||||
constructor(networkService: NetworkService) {
|
constructor(networkService: NetworkService) {
|
||||||
// 不查询任何实体,此系统只处理输入
|
super(Matcher.nothing())
|
||||||
// Don't query any entities, this system only handles input
|
this._networkService = networkService
|
||||||
super(Matcher.nothing());
|
|
||||||
this._networkService = networkService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理输入队列
|
* @zh 处理输入队列
|
||||||
* Process input queue
|
* @en Process input queue
|
||||||
*/
|
*/
|
||||||
protected override process(): void {
|
protected override process(): void {
|
||||||
if (!this._networkService.isConnected) return;
|
if (!this._networkService.isConnected) return
|
||||||
|
|
||||||
this._frame++;
|
this._frame++
|
||||||
|
|
||||||
// 发送队列中的输入
|
|
||||||
// Send queued inputs
|
|
||||||
while (this._inputQueue.length > 0) {
|
while (this._inputQueue.length > 0) {
|
||||||
const input = this._inputQueue.shift()!;
|
const input = this._inputQueue.shift()!
|
||||||
input.frame = this._frame;
|
input.frame = this._frame
|
||||||
this._networkService.sendInput(input);
|
this._networkService.sendInput(input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加移动输入
|
* @zh 添加移动输入
|
||||||
* Add move input
|
* @en Add move input
|
||||||
*/
|
*/
|
||||||
public addMoveInput(x: number, y: number): void {
|
public addMoveInput(x: number, y: number): void {
|
||||||
this._inputQueue.push({
|
this._inputQueue.push({
|
||||||
frame: 0,
|
frame: 0,
|
||||||
moveDir: { x, y }
|
moveDir: { x, y },
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加动作输入
|
* @zh 添加动作输入
|
||||||
* Add action input
|
* @en Add action input
|
||||||
*/
|
*/
|
||||||
public addActionInput(action: string): void {
|
public addActionInput(action: string): void {
|
||||||
const lastInput = this._inputQueue[this._inputQueue.length - 1];
|
const lastInput = this._inputQueue[this._inputQueue.length - 1]
|
||||||
if (lastInput) {
|
if (lastInput) {
|
||||||
lastInput.actions = lastInput.actions || [];
|
lastInput.actions = lastInput.actions || []
|
||||||
lastInput.actions.push(action);
|
lastInput.actions.push(action)
|
||||||
} else {
|
} else {
|
||||||
this._inputQueue.push({
|
this._inputQueue.push({
|
||||||
frame: 0,
|
frame: 0,
|
||||||
actions: [action]
|
actions: [action],
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override onDestroy(): void {
|
protected override onDestroy(): void {
|
||||||
this._inputQueue.length = 0;
|
this._inputQueue.length = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +1,123 @@
|
|||||||
import { EntitySystem, Entity, type Scene, Matcher } from '@esengine/ecs-framework';
|
import { EntitySystem, Entity, type Scene, Matcher } from '@esengine/ecs-framework'
|
||||||
import type { MsgSpawn, MsgDespawn } from '@esengine/network-protocols';
|
import { NetworkIdentity } from '../components/NetworkIdentity'
|
||||||
import { NetworkIdentity } from '../components/NetworkIdentity';
|
import { NetworkTransform } from '../components/NetworkTransform'
|
||||||
import { NetworkTransform } from '../components/NetworkTransform';
|
import type { NetworkSyncSystem } from './NetworkSyncSystem'
|
||||||
import type { NetworkService } from '../services/NetworkService';
|
|
||||||
import type { NetworkSyncSystem } from './NetworkSyncSystem';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预制体工厂函数类型
|
* @zh 生成消息接口
|
||||||
* Prefab factory function type
|
* @en Spawn message interface
|
||||||
*/
|
*/
|
||||||
export type PrefabFactory = (scene: Scene, spawn: MsgSpawn) => Entity;
|
export interface SpawnMessage {
|
||||||
|
netId: number
|
||||||
|
ownerId: number
|
||||||
|
prefab: string
|
||||||
|
pos: { x: number; y: number }
|
||||||
|
rot?: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 网络生成系统
|
* @zh 销毁消息接口
|
||||||
* Network spawn system
|
* @en Despawn message interface
|
||||||
|
*/
|
||||||
|
export interface DespawnMessage {
|
||||||
|
netId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 预制体工厂函数类型
|
||||||
|
* @en Prefab factory function type
|
||||||
|
*/
|
||||||
|
export type PrefabFactory = (scene: Scene, spawn: SpawnMessage) => Entity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 网络生成系统
|
||||||
|
* @en Network spawn system
|
||||||
*
|
*
|
||||||
* 处理网络实体的生成和销毁。
|
* @zh 处理网络实体的生成和销毁
|
||||||
* Handles spawning and despawning of networked entities.
|
* @en Handles spawning and despawning of networked entities
|
||||||
*/
|
*/
|
||||||
export class NetworkSpawnSystem extends EntitySystem {
|
export class NetworkSpawnSystem extends EntitySystem {
|
||||||
private _networkService: NetworkService;
|
private _syncSystem: NetworkSyncSystem
|
||||||
private _syncSystem: NetworkSyncSystem;
|
private _prefabFactories: Map<string, PrefabFactory> = new Map()
|
||||||
private _prefabFactories: Map<string, PrefabFactory> = new Map();
|
private _localPlayerId: number = 0
|
||||||
|
|
||||||
constructor(networkService: NetworkService, syncSystem: NetworkSyncSystem) {
|
constructor(syncSystem: NetworkSyncSystem) {
|
||||||
// 不查询任何实体,此系统只响应网络消息
|
super(Matcher.nothing())
|
||||||
// Don't query any entities, this system only responds to network messages
|
this._syncSystem = syncSystem
|
||||||
super(Matcher.nothing());
|
|
||||||
this._networkService = networkService;
|
|
||||||
this._syncSystem = syncSystem;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override onInitialize(): void {
|
|
||||||
this._networkService.setCallbacks({
|
|
||||||
onSpawn: this._handleSpawn.bind(this),
|
|
||||||
onDespawn: this._handleDespawn.bind(this)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册预制体工厂
|
* @zh 设置本地玩家 ID
|
||||||
* Register prefab factory
|
* @en Set local player ID
|
||||||
*/
|
*/
|
||||||
public registerPrefab(prefabType: string, factory: PrefabFactory): void {
|
setLocalPlayerId(id: number): void {
|
||||||
this._prefabFactories.set(prefabType, factory);
|
this._localPlayerId = id
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注销预制体工厂
|
* @zh 处理生成消息
|
||||||
* Unregister prefab factory
|
* @en Handle spawn message
|
||||||
*/
|
*/
|
||||||
public unregisterPrefab(prefabType: string): void {
|
handleSpawn(msg: SpawnMessage): Entity | null {
|
||||||
this._prefabFactories.delete(prefabType);
|
if (!this.scene) return null
|
||||||
}
|
|
||||||
|
|
||||||
private _handleSpawn(msg: MsgSpawn): void {
|
const factory = this._prefabFactories.get(msg.prefab)
|
||||||
if (!this.scene) return;
|
|
||||||
|
|
||||||
const factory = this._prefabFactories.get(msg.prefab);
|
|
||||||
if (!factory) {
|
if (!factory) {
|
||||||
this.logger.warn(`Unknown prefab: ${msg.prefab}`);
|
this.logger.warn(`Unknown prefab: ${msg.prefab}`)
|
||||||
return;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const entity = factory(this.scene, msg);
|
const entity = factory(this.scene, msg)
|
||||||
|
|
||||||
// 添加网络组件
|
const identity = entity.addComponent(new NetworkIdentity())
|
||||||
// Add network components
|
identity.netId = msg.netId
|
||||||
const identity = entity.addComponent(new NetworkIdentity());
|
identity.ownerId = msg.ownerId
|
||||||
identity.netId = msg.netId;
|
identity.prefabType = msg.prefab
|
||||||
identity.ownerId = msg.ownerId;
|
identity.bHasAuthority = msg.ownerId === this._localPlayerId
|
||||||
identity.prefabType = msg.prefab;
|
identity.bIsLocalPlayer = identity.bHasAuthority
|
||||||
identity.bHasAuthority = msg.ownerId === this._networkService.clientId;
|
|
||||||
identity.bIsLocalPlayer = identity.bHasAuthority;
|
|
||||||
|
|
||||||
const transform = entity.addComponent(new NetworkTransform());
|
const transform = entity.addComponent(new NetworkTransform())
|
||||||
transform.setTarget(msg.pos.x, msg.pos.y, msg.rot);
|
transform.setTarget(msg.pos.x, msg.pos.y, msg.rot ?? 0)
|
||||||
transform.snap();
|
transform.snap()
|
||||||
|
|
||||||
// 注册到同步系统
|
this._syncSystem.registerEntity(msg.netId, entity.id)
|
||||||
// Register to sync system
|
|
||||||
this._syncSystem.registerEntity(msg.netId, entity.id);
|
return entity
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handleDespawn(msg: MsgDespawn): void {
|
/**
|
||||||
const entityId = this._syncSystem.getEntityId(msg.netId);
|
* @zh 处理销毁消息
|
||||||
if (entityId === undefined) return;
|
* @en Handle despawn message
|
||||||
|
*/
|
||||||
|
handleDespawn(msg: DespawnMessage): void {
|
||||||
|
const entityId = this._syncSystem.getEntityId(msg.netId)
|
||||||
|
if (entityId === undefined) return
|
||||||
|
|
||||||
const entity = this.scene?.findEntityById(entityId);
|
const entity = this.scene?.findEntityById(entityId)
|
||||||
if (entity) {
|
if (entity) {
|
||||||
entity.destroy();
|
entity.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
this._syncSystem.unregisterEntity(msg.netId);
|
this._syncSystem.unregisterEntity(msg.netId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 注册预制体工厂
|
||||||
|
* @en Register prefab factory
|
||||||
|
*/
|
||||||
|
registerPrefab(prefabType: string, factory: PrefabFactory): void {
|
||||||
|
this._prefabFactories.set(prefabType, factory)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 注销预制体工厂
|
||||||
|
* @en Unregister prefab factory
|
||||||
|
*/
|
||||||
|
unregisterPrefab(prefabType: string): void {
|
||||||
|
this._prefabFactories.delete(prefabType)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override onDestroy(): void {
|
protected override onDestroy(): void {
|
||||||
this._prefabFactories.clear();
|
this._prefabFactories.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +1,102 @@
|
|||||||
import { EntitySystem, Matcher, Time, type Entity } from '@esengine/ecs-framework';
|
import { EntitySystem, Matcher, Time, type Entity } from '@esengine/ecs-framework'
|
||||||
import type { MsgSync } from '@esengine/network-protocols';
|
import { NetworkIdentity } from '../components/NetworkIdentity'
|
||||||
import { NetworkIdentity } from '../components/NetworkIdentity';
|
import { NetworkTransform } from '../components/NetworkTransform'
|
||||||
import { NetworkTransform } from '../components/NetworkTransform';
|
|
||||||
import type { NetworkService } from '../services/NetworkService';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 网络同步系统
|
* @zh 同步消息接口
|
||||||
* Network sync system
|
* @en Sync message interface
|
||||||
|
*/
|
||||||
|
export interface SyncMessage {
|
||||||
|
entities: Array<{
|
||||||
|
netId: number
|
||||||
|
pos?: { x: number; y: number }
|
||||||
|
rot?: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 网络同步系统
|
||||||
|
* @en Network sync system
|
||||||
*
|
*
|
||||||
* 处理网络实体的状态同步和插值。
|
* @zh 处理网络实体的状态同步和插值
|
||||||
* Handles state synchronization and interpolation for networked entities.
|
* @en Handles state synchronization and interpolation for networked entities
|
||||||
*/
|
*/
|
||||||
export class NetworkSyncSystem extends EntitySystem {
|
export class NetworkSyncSystem extends EntitySystem {
|
||||||
private _networkService: NetworkService;
|
private _netIdToEntity: Map<number, number> = new Map()
|
||||||
private _netIdToEntity: Map<number, number> = new Map();
|
|
||||||
|
|
||||||
constructor(networkService: NetworkService) {
|
constructor() {
|
||||||
super(Matcher.all(NetworkIdentity, NetworkTransform));
|
super(Matcher.all(NetworkIdentity, NetworkTransform))
|
||||||
this._networkService = networkService;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override onInitialize(): void {
|
|
||||||
this._networkService.setCallbacks({
|
|
||||||
onSync: this._handleSync.bind(this)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理实体列表
|
* @zh 处理同步消息
|
||||||
* Process entities
|
* @en Handle sync message
|
||||||
*/
|
*/
|
||||||
|
handleSync(msg: SyncMessage): void {
|
||||||
|
for (const state of msg.entities) {
|
||||||
|
const entityId = this._netIdToEntity.get(state.netId)
|
||||||
|
if (entityId === undefined) continue
|
||||||
|
|
||||||
|
const entity = this.scene?.findEntityById(entityId)
|
||||||
|
if (!entity) continue
|
||||||
|
|
||||||
|
const transform = entity.getComponent(NetworkTransform)
|
||||||
|
if (transform && state.pos) {
|
||||||
|
transform.setTarget(state.pos.x, state.pos.y, state.rot ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override process(entities: readonly Entity[]): void {
|
protected override process(entities: readonly Entity[]): void {
|
||||||
const deltaTime = Time.deltaTime;
|
const deltaTime = Time.deltaTime
|
||||||
|
|
||||||
for (const entity of entities) {
|
for (const entity of entities) {
|
||||||
const transform = this.requireComponent(entity, NetworkTransform);
|
const transform = this.requireComponent(entity, NetworkTransform)
|
||||||
const identity = this.requireComponent(entity, NetworkIdentity);
|
const identity = this.requireComponent(entity, NetworkIdentity)
|
||||||
|
|
||||||
// 只有非本地玩家需要插值
|
|
||||||
// Only non-local players need interpolation
|
|
||||||
if (!identity.bHasAuthority && transform.bInterpolate) {
|
if (!identity.bHasAuthority && transform.bInterpolate) {
|
||||||
this._interpolate(transform, deltaTime);
|
this._interpolate(transform, deltaTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册网络实体
|
* @zh 注册网络实体
|
||||||
* Register network entity
|
* @en Register network entity
|
||||||
*/
|
*/
|
||||||
public registerEntity(netId: number, entityId: number): void {
|
registerEntity(netId: number, entityId: number): void {
|
||||||
this._netIdToEntity.set(netId, entityId);
|
this._netIdToEntity.set(netId, entityId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注销网络实体
|
* @zh 注销网络实体
|
||||||
* Unregister network entity
|
* @en Unregister network entity
|
||||||
*/
|
*/
|
||||||
public unregisterEntity(netId: number): void {
|
unregisterEntity(netId: number): void {
|
||||||
this._netIdToEntity.delete(netId);
|
this._netIdToEntity.delete(netId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据网络 ID 获取实体 ID
|
* @zh 根据网络 ID 获取实体 ID
|
||||||
* Get entity ID by network ID
|
* @en Get entity ID by network ID
|
||||||
*/
|
*/
|
||||||
public getEntityId(netId: number): number | undefined {
|
getEntityId(netId: number): number | undefined {
|
||||||
return this._netIdToEntity.get(netId);
|
return this._netIdToEntity.get(netId)
|
||||||
}
|
|
||||||
|
|
||||||
private _handleSync(msg: MsgSync): void {
|
|
||||||
for (const state of msg.entities) {
|
|
||||||
const entityId = this._netIdToEntity.get(state.netId);
|
|
||||||
if (entityId === undefined) continue;
|
|
||||||
|
|
||||||
const entity = this.scene?.findEntityById(entityId);
|
|
||||||
if (!entity) continue;
|
|
||||||
|
|
||||||
const transform = entity.getComponent(NetworkTransform);
|
|
||||||
if (transform && state.pos) {
|
|
||||||
transform.setTarget(state.pos.x, state.pos.y, state.rot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _interpolate(transform: NetworkTransform, deltaTime: number): void {
|
private _interpolate(transform: NetworkTransform, deltaTime: number): void {
|
||||||
const t = Math.min(1, transform.lerpSpeed * deltaTime);
|
const t = Math.min(1, transform.lerpSpeed * deltaTime)
|
||||||
|
|
||||||
transform.currentX += (transform.targetX - transform.currentX) * t;
|
transform.currentX += (transform.targetX - transform.currentX) * t
|
||||||
transform.currentY += (transform.targetY - transform.currentY) * t;
|
transform.currentY += (transform.targetY - transform.currentY) * t
|
||||||
|
|
||||||
// 角度插值需要处理环绕
|
let angleDiff = transform.targetRotation - transform.currentRotation
|
||||||
// Angle interpolation needs to handle wrap-around
|
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2
|
||||||
let angleDiff = transform.targetRotation - transform.currentRotation;
|
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2
|
||||||
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
|
transform.currentRotation += angleDiff * t
|
||||||
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2;
|
|
||||||
transform.currentRotation += angleDiff * t;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override onDestroy(): void {
|
protected override onDestroy(): void {
|
||||||
this._netIdToEntity.clear();
|
this._netIdToEntity.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,8 @@
|
|||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src"
|
||||||
"moduleResolution": "node"
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "**/*.test.ts"],
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
"references": [
|
|
||||||
{ "path": "../blueprint" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,12 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src"
|
||||||
"moduleResolution": "node"
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src/**/*"],
|
||||||
"src/**/*"
|
"exclude": ["node_modules", "dist"],
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules",
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{ "path": "../core" },
|
||||||
"path": "../core"
|
{ "path": "../blueprint" }
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../network-protocols"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../blueprint"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { defineConfig } from 'tsup';
|
import { defineConfig } from 'tsup'
|
||||||
import { runtimeOnlyPreset } from '../../tools/build-config/src/presets/plugin-tsup';
|
import { runtimeOnlyPreset } from '../../tools/build-config/src/presets/plugin-tsup'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
...runtimeOnlyPreset({
|
...runtimeOnlyPreset({}),
|
||||||
external: [/^tsrpc/, 'tsbuffer', 'tsbuffer-schema']
|
|
||||||
}),
|
|
||||||
tsconfig: 'tsconfig.build.json',
|
tsconfig: 'tsconfig.build.json',
|
||||||
// 禁用 tsup 的 DTS 捆绑器,改用 tsc 生成声明文件
|
// tsup 的 DTS bundler 无法正确解析 workspace 包的类型继承链
|
||||||
// tsup 的 DTS bundler 无法正确解析 tsrpc 的类型继承链
|
// tsup's DTS bundler cannot correctly resolve workspace package type inheritance
|
||||||
// Disable tsup's DTS bundler, use tsc to generate declarations
|
dts: false,
|
||||||
// tsup's DTS bundler cannot correctly resolve tsrpc's type inheritance chain
|
})
|
||||||
dts: false
|
|
||||||
});
|
|
||||||
|
|||||||
20
packages/framework/rpc/module.json
Normal file
20
packages/framework/rpc/module.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"id": "rpc",
|
||||||
|
"name": "@esengine/rpc",
|
||||||
|
"globalKey": "rpc",
|
||||||
|
"displayName": "RPC",
|
||||||
|
"description": "类型安全的 RPC 通信库 | Type-safe RPC communication library",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"category": "Network",
|
||||||
|
"icon": "Network",
|
||||||
|
"tags": ["rpc", "websocket", "network", "multiplayer"],
|
||||||
|
"isCore": false,
|
||||||
|
"defaultEnabled": true,
|
||||||
|
"isEngineModule": true,
|
||||||
|
"canContainContent": false,
|
||||||
|
"platforms": ["web", "desktop", "node"],
|
||||||
|
"dependencies": [],
|
||||||
|
"exports": {},
|
||||||
|
"requiresWasm": false,
|
||||||
|
"outputPath": "dist/index.js"
|
||||||
|
}
|
||||||
60
packages/framework/rpc/package.json
Normal file
60
packages/framework/rpc/package.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"name": "@esengine/rpc",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Elegant type-safe RPC library for ESEngine",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
},
|
||||||
|
"./server": {
|
||||||
|
"import": "./dist/server/index.js",
|
||||||
|
"types": "./dist/server/index.d.ts"
|
||||||
|
},
|
||||||
|
"./client": {
|
||||||
|
"import": "./dist/client/index.js",
|
||||||
|
"types": "./dist/client/index.d.ts"
|
||||||
|
},
|
||||||
|
"./codec": {
|
||||||
|
"import": "./dist/codec/index.js",
|
||||||
|
"types": "./dist/codec/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"module.json"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup && tsc --emitDeclarationOnly",
|
||||||
|
"build:watch": "tsup --watch",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"clean": "rimraf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"msgpackr": "^1.11.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/ws": "^8.5.13",
|
||||||
|
"msgpackr": "^1.11.0",
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"tsup": "^8.0.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=5.0.0",
|
||||||
|
"ws": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ws": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
461
packages/framework/rpc/src/client/index.ts
Normal file
461
packages/framework/rpc/src/client/index.ts
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
/**
|
||||||
|
* @zh RPC 客户端模块
|
||||||
|
* @en RPC Client Module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProtocolDef,
|
||||||
|
ApiNames,
|
||||||
|
MsgNames,
|
||||||
|
ApiInput,
|
||||||
|
ApiOutput,
|
||||||
|
MsgData,
|
||||||
|
Packet,
|
||||||
|
ConnectionStatus,
|
||||||
|
} from '../types'
|
||||||
|
import { RpcError, ErrorCode } from '../types'
|
||||||
|
import { json } from '../codec/json'
|
||||||
|
import type { Codec } from '../codec/types'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Re-exports | 类型重导出
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ProtocolDef,
|
||||||
|
ApiNames,
|
||||||
|
MsgNames,
|
||||||
|
ApiInput,
|
||||||
|
ApiOutput,
|
||||||
|
MsgData,
|
||||||
|
ConnectionStatus,
|
||||||
|
Codec,
|
||||||
|
}
|
||||||
|
export { RpcError, ErrorCode }
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types | 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh WebSocket 适配器接口
|
||||||
|
* @en WebSocket adapter interface
|
||||||
|
*
|
||||||
|
* @zh 用于适配不同平台的 WebSocket 实现(浏览器、微信小游戏等)
|
||||||
|
* @en Used to adapt different platform WebSocket implementations (browser, WeChat Mini Games, etc.)
|
||||||
|
*/
|
||||||
|
export interface WebSocketAdapter {
|
||||||
|
readonly readyState: number
|
||||||
|
send(data: string | ArrayBuffer): void
|
||||||
|
close(code?: number, reason?: string): void
|
||||||
|
onopen: ((ev: Event) => void) | null
|
||||||
|
onclose: ((ev: { code: number; reason: string }) => void) | null
|
||||||
|
onerror: ((ev: Event) => void) | null
|
||||||
|
onmessage: ((ev: { data: string | ArrayBuffer }) => void) | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh WebSocket 工厂函数类型
|
||||||
|
* @en WebSocket factory function type
|
||||||
|
*/
|
||||||
|
export type WebSocketFactory = (url: string) => WebSocketAdapter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 客户端配置
|
||||||
|
* @en Client options
|
||||||
|
*/
|
||||||
|
export interface RpcClientOptions {
|
||||||
|
/**
|
||||||
|
* @zh 编解码器
|
||||||
|
* @en Codec
|
||||||
|
* @defaultValue json()
|
||||||
|
*/
|
||||||
|
codec?: Codec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh API 调用超时(毫秒)
|
||||||
|
* @en API call timeout in milliseconds
|
||||||
|
* @defaultValue 30000
|
||||||
|
*/
|
||||||
|
timeout?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 自动重连
|
||||||
|
* @en Auto reconnect
|
||||||
|
* @defaultValue true
|
||||||
|
*/
|
||||||
|
autoReconnect?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 重连间隔(毫秒)
|
||||||
|
* @en Reconnect interval in milliseconds
|
||||||
|
* @defaultValue 3000
|
||||||
|
*/
|
||||||
|
reconnectInterval?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh WebSocket 工厂函数
|
||||||
|
* @en WebSocket factory function
|
||||||
|
*
|
||||||
|
* @zh 用于自定义 WebSocket 实现,如微信小游戏
|
||||||
|
* @en Used for custom WebSocket implementation, e.g., WeChat Mini Games
|
||||||
|
*/
|
||||||
|
webSocketFactory?: WebSocketFactory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接成功回调
|
||||||
|
* @en Connection established callback
|
||||||
|
*/
|
||||||
|
onConnect?: () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接断开回调
|
||||||
|
* @en Connection closed callback
|
||||||
|
*/
|
||||||
|
onDisconnect?: (reason?: string) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 错误回调
|
||||||
|
* @en Error callback
|
||||||
|
*/
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use RpcClientOptions instead */
|
||||||
|
export type ConnectOptions = RpcClientOptions
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants | 常量
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const PacketType = {
|
||||||
|
ApiRequest: 0,
|
||||||
|
ApiResponse: 1,
|
||||||
|
ApiError: 2,
|
||||||
|
Message: 3,
|
||||||
|
Heartbeat: 9,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const defaultWebSocketFactory: WebSocketFactory = (url) =>
|
||||||
|
new WebSocket(url) as unknown as WebSocketAdapter
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RpcClient Class | RPC 客户端类
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface PendingCall {
|
||||||
|
resolve: (value: unknown) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
|
timer: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh RPC 客户端
|
||||||
|
* @en RPC Client
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const client = new RpcClient(protocol, 'ws://localhost:3000')
|
||||||
|
* await client.connect()
|
||||||
|
*
|
||||||
|
* const result = await client.call('join', { name: 'Alice' })
|
||||||
|
*
|
||||||
|
* client.on('chat', (msg) => console.log(msg.text))
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class RpcClient<P extends ProtocolDef> {
|
||||||
|
private readonly _url: string
|
||||||
|
private readonly _codec: Codec
|
||||||
|
private readonly _timeout: number
|
||||||
|
private readonly _reconnectInterval: number
|
||||||
|
private readonly _wsFactory: WebSocketFactory
|
||||||
|
private readonly _options: RpcClientOptions
|
||||||
|
|
||||||
|
private _ws: WebSocketAdapter | null = null
|
||||||
|
private _status: ConnectionStatus = 'closed'
|
||||||
|
private _callIdCounter = 0
|
||||||
|
private _shouldReconnect: boolean
|
||||||
|
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
private readonly _pendingCalls = new Map<number, PendingCall>()
|
||||||
|
private readonly _msgHandlers = new Map<string, Set<(data: unknown) => void>>()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
_protocol: P,
|
||||||
|
url: string,
|
||||||
|
options: RpcClientOptions = {}
|
||||||
|
) {
|
||||||
|
this._url = url
|
||||||
|
this._options = options
|
||||||
|
this._codec = options.codec ?? json()
|
||||||
|
this._timeout = options.timeout ?? 30000
|
||||||
|
this._shouldReconnect = options.autoReconnect ?? true
|
||||||
|
this._reconnectInterval = options.reconnectInterval ?? 3000
|
||||||
|
this._wsFactory = options.webSocketFactory ?? defaultWebSocketFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接状态
|
||||||
|
* @en Connection status
|
||||||
|
*/
|
||||||
|
get status(): ConnectionStatus {
|
||||||
|
return this._status
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否已连接
|
||||||
|
* @en Whether connected
|
||||||
|
*/
|
||||||
|
get isConnected(): boolean {
|
||||||
|
return this._status === 'open'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接到服务器
|
||||||
|
* @en Connect to server
|
||||||
|
*/
|
||||||
|
connect(): Promise<this> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (this._status === 'open' || this._status === 'connecting') {
|
||||||
|
resolve(this)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._status = 'connecting'
|
||||||
|
this._ws = this._wsFactory(this._url)
|
||||||
|
|
||||||
|
this._ws.onopen = () => {
|
||||||
|
this._status = 'open'
|
||||||
|
this._options.onConnect?.()
|
||||||
|
resolve(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
this._ws.onclose = (e) => {
|
||||||
|
this._status = 'closed'
|
||||||
|
this._rejectAllPending()
|
||||||
|
this._options.onDisconnect?.(e.reason)
|
||||||
|
this._scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
this._ws.onerror = () => {
|
||||||
|
const err = new Error('WebSocket error')
|
||||||
|
this._options.onError?.(err)
|
||||||
|
if (this._status === 'connecting') {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._ws.onmessage = (e) => {
|
||||||
|
this._handleMessage(e.data as string | ArrayBuffer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 断开连接
|
||||||
|
* @en Disconnect
|
||||||
|
*/
|
||||||
|
disconnect(): void {
|
||||||
|
this._shouldReconnect = false
|
||||||
|
this._clearReconnectTimer()
|
||||||
|
if (this._ws) {
|
||||||
|
this._status = 'closing'
|
||||||
|
this._ws.close()
|
||||||
|
this._ws = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 调用 API
|
||||||
|
* @en Call API
|
||||||
|
*/
|
||||||
|
call<K extends ApiNames<P>>(
|
||||||
|
name: K,
|
||||||
|
input: ApiInput<P['api'][K]>
|
||||||
|
): Promise<ApiOutput<P['api'][K]>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (this._status !== 'open') {
|
||||||
|
reject(new RpcError(ErrorCode.CONNECTION_CLOSED, 'Not connected'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = ++this._callIdCounter
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this._pendingCalls.delete(id)
|
||||||
|
reject(new RpcError(ErrorCode.TIMEOUT, 'Request timeout'))
|
||||||
|
}, this._timeout)
|
||||||
|
|
||||||
|
this._pendingCalls.set(id, {
|
||||||
|
resolve: resolve as (v: unknown) => void,
|
||||||
|
reject,
|
||||||
|
timer,
|
||||||
|
})
|
||||||
|
|
||||||
|
const packet: Packet = [PacketType.ApiRequest, id, name as string, input]
|
||||||
|
this._ws!.send(this._codec.encode(packet) as string | ArrayBuffer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 发送消息
|
||||||
|
* @en Send message
|
||||||
|
*/
|
||||||
|
send<K extends MsgNames<P>>(name: K, data: MsgData<P['msg'][K]>): void {
|
||||||
|
if (this._status !== 'open') return
|
||||||
|
const packet: Packet = [PacketType.Message, name as string, data]
|
||||||
|
this._ws!.send(this._codec.encode(packet) as string | ArrayBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 监听消息
|
||||||
|
* @en Listen to message
|
||||||
|
*/
|
||||||
|
on<K extends MsgNames<P>>(
|
||||||
|
name: K,
|
||||||
|
handler: (data: MsgData<P['msg'][K]>) => void
|
||||||
|
): this {
|
||||||
|
const key = name as string
|
||||||
|
let handlers = this._msgHandlers.get(key)
|
||||||
|
if (!handlers) {
|
||||||
|
handlers = new Set()
|
||||||
|
this._msgHandlers.set(key, handlers)
|
||||||
|
}
|
||||||
|
handlers.add(handler as (data: unknown) => void)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 取消监听消息
|
||||||
|
* @en Remove message listener
|
||||||
|
*/
|
||||||
|
off<K extends MsgNames<P>>(
|
||||||
|
name: K,
|
||||||
|
handler?: (data: MsgData<P['msg'][K]>) => void
|
||||||
|
): this {
|
||||||
|
const key = name as string
|
||||||
|
if (handler) {
|
||||||
|
this._msgHandlers.get(key)?.delete(handler as (data: unknown) => void)
|
||||||
|
} else {
|
||||||
|
this._msgHandlers.delete(key)
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 监听消息(只触发一次)
|
||||||
|
* @en Listen to message (once)
|
||||||
|
*/
|
||||||
|
once<K extends MsgNames<P>>(
|
||||||
|
name: K,
|
||||||
|
handler: (data: MsgData<P['msg'][K]>) => void
|
||||||
|
): this {
|
||||||
|
const wrapper = (data: MsgData<P['msg'][K]>) => {
|
||||||
|
this.off(name, wrapper)
|
||||||
|
handler(data)
|
||||||
|
}
|
||||||
|
return this.on(name, wrapper)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Private Methods | 私有方法
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
private _handleMessage(raw: string | ArrayBuffer): void {
|
||||||
|
try {
|
||||||
|
const data = typeof raw === 'string' ? raw : new Uint8Array(raw)
|
||||||
|
const packet = this._codec.decode(data)
|
||||||
|
const type = packet[0]
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case PacketType.ApiResponse:
|
||||||
|
this._handleApiResponse(packet as [number, number, unknown])
|
||||||
|
break
|
||||||
|
case PacketType.ApiError:
|
||||||
|
this._handleApiError(packet as [number, number, string, string])
|
||||||
|
break
|
||||||
|
case PacketType.Message:
|
||||||
|
this._handleMsg(packet as [number, string, unknown])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this._options.onError?.(err as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleApiResponse([, id, result]: [number, number, unknown]): void {
|
||||||
|
const pending = this._pendingCalls.get(id)
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
this._pendingCalls.delete(id)
|
||||||
|
pending.resolve(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleApiError([, id, code, message]: [number, number, string, string]): void {
|
||||||
|
const pending = this._pendingCalls.get(id)
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
this._pendingCalls.delete(id)
|
||||||
|
pending.reject(new RpcError(code, message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleMsg([, path, data]: [number, string, unknown]): void {
|
||||||
|
const handlers = this._msgHandlers.get(path)
|
||||||
|
if (handlers) {
|
||||||
|
for (const handler of handlers) {
|
||||||
|
try {
|
||||||
|
handler(data)
|
||||||
|
} catch (err) {
|
||||||
|
this._options.onError?.(err as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _rejectAllPending(): void {
|
||||||
|
for (const [, pending] of this._pendingCalls) {
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
pending.reject(new RpcError(ErrorCode.CONNECTION_CLOSED, 'Connection closed'))
|
||||||
|
}
|
||||||
|
this._pendingCalls.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private _scheduleReconnect(): void {
|
||||||
|
if (this._shouldReconnect && !this._reconnectTimer) {
|
||||||
|
this._reconnectTimer = setTimeout(() => {
|
||||||
|
this._reconnectTimer = null
|
||||||
|
this.connect().catch(() => {})
|
||||||
|
}, this._reconnectInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _clearReconnectTimer(): void {
|
||||||
|
if (this._reconnectTimer) {
|
||||||
|
clearTimeout(this._reconnectTimer)
|
||||||
|
this._reconnectTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Function | 工厂函数
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接到 RPC 服务器(便捷函数)
|
||||||
|
* @en Connect to RPC server (convenience function)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const client = await connect(protocol, 'ws://localhost:3000')
|
||||||
|
* const result = await client.call('join', { name: 'Alice' })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function connect<P extends ProtocolDef>(
|
||||||
|
protocol: P,
|
||||||
|
url: string,
|
||||||
|
options: RpcClientOptions = {}
|
||||||
|
): Promise<RpcClient<P>> {
|
||||||
|
return new RpcClient(protocol, url, options).connect()
|
||||||
|
}
|
||||||
9
packages/framework/rpc/src/codec/index.ts
Normal file
9
packages/framework/rpc/src/codec/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @zh 编解码器模块
|
||||||
|
* @en Codec Module
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type { Codec } from './types'
|
||||||
|
export { json } from './json'
|
||||||
|
export { msgpack } from './msgpack'
|
||||||
|
export { textEncode, textDecode } from './polyfill'
|
||||||
30
packages/framework/rpc/src/codec/json.ts
Normal file
30
packages/framework/rpc/src/codec/json.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* @zh JSON 编解码器
|
||||||
|
* @en JSON Codec
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Packet } from '../types'
|
||||||
|
import type { Codec } from './types'
|
||||||
|
import { textDecode } from './polyfill'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建 JSON 编解码器
|
||||||
|
* @en Create JSON codec
|
||||||
|
*
|
||||||
|
* @zh 适用于开发调试,可读性好
|
||||||
|
* @en Suitable for development, human-readable
|
||||||
|
*/
|
||||||
|
export function json(): Codec {
|
||||||
|
return {
|
||||||
|
encode(packet: Packet): string {
|
||||||
|
return JSON.stringify(packet)
|
||||||
|
},
|
||||||
|
|
||||||
|
decode(data: string | Uint8Array): Packet {
|
||||||
|
const str = typeof data === 'string'
|
||||||
|
? data
|
||||||
|
: textDecode(data)
|
||||||
|
return JSON.parse(str) as Packet
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
34
packages/framework/rpc/src/codec/msgpack.ts
Normal file
34
packages/framework/rpc/src/codec/msgpack.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* @zh MessagePack 编解码器
|
||||||
|
* @en MessagePack Codec
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Packr, Unpackr } from 'msgpackr'
|
||||||
|
import type { Packet } from '../types'
|
||||||
|
import type { Codec } from './types'
|
||||||
|
import { textEncode } from './polyfill'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建 MessagePack 编解码器
|
||||||
|
* @en Create MessagePack codec
|
||||||
|
*
|
||||||
|
* @zh 适用于生产环境,体积更小、速度更快
|
||||||
|
* @en Suitable for production, smaller size and faster speed
|
||||||
|
*/
|
||||||
|
export function msgpack(): Codec {
|
||||||
|
const encoder = new Packr({ structuredClone: true })
|
||||||
|
const decoder = new Unpackr({ structuredClone: true })
|
||||||
|
|
||||||
|
return {
|
||||||
|
encode(packet: Packet): Uint8Array {
|
||||||
|
return encoder.pack(packet)
|
||||||
|
},
|
||||||
|
|
||||||
|
decode(data: string | Uint8Array): Packet {
|
||||||
|
const buf = typeof data === 'string'
|
||||||
|
? textEncode(data)
|
||||||
|
: data
|
||||||
|
return decoder.unpack(buf) as Packet
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
112
packages/framework/rpc/src/codec/polyfill.ts
Normal file
112
packages/framework/rpc/src/codec/polyfill.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* @zh 平台兼容性 polyfill
|
||||||
|
* @en Platform compatibility polyfill
|
||||||
|
*
|
||||||
|
* @zh 为微信小游戏等不支持原生 TextEncoder/TextDecoder 的平台提供兼容层
|
||||||
|
* @en Provides compatibility layer for platforms like WeChat Mini Games that don't support native TextEncoder/TextDecoder
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取全局 TextEncoder 实现
|
||||||
|
* @en Get global TextEncoder implementation
|
||||||
|
*/
|
||||||
|
function getTextEncoder(): { encode(str: string): Uint8Array } {
|
||||||
|
if (typeof TextEncoder !== 'undefined') {
|
||||||
|
return new TextEncoder()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
encode(str: string): Uint8Array {
|
||||||
|
const utf8: number[] = []
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
let charCode = str.charCodeAt(i)
|
||||||
|
if (charCode < 0x80) {
|
||||||
|
utf8.push(charCode)
|
||||||
|
} else if (charCode < 0x800) {
|
||||||
|
utf8.push(0xc0 | (charCode >> 6), 0x80 | (charCode & 0x3f))
|
||||||
|
} else if (charCode >= 0xd800 && charCode <= 0xdbff) {
|
||||||
|
i++
|
||||||
|
const low = str.charCodeAt(i)
|
||||||
|
charCode = 0x10000 + ((charCode - 0xd800) << 10) + (low - 0xdc00)
|
||||||
|
utf8.push(
|
||||||
|
0xf0 | (charCode >> 18),
|
||||||
|
0x80 | ((charCode >> 12) & 0x3f),
|
||||||
|
0x80 | ((charCode >> 6) & 0x3f),
|
||||||
|
0x80 | (charCode & 0x3f)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
utf8.push(
|
||||||
|
0xe0 | (charCode >> 12),
|
||||||
|
0x80 | ((charCode >> 6) & 0x3f),
|
||||||
|
0x80 | (charCode & 0x3f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Uint8Array(utf8)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取全局 TextDecoder 实现
|
||||||
|
* @en Get global TextDecoder implementation
|
||||||
|
*/
|
||||||
|
function getTextDecoder(): { decode(data: Uint8Array): string } {
|
||||||
|
if (typeof TextDecoder !== 'undefined') {
|
||||||
|
return new TextDecoder()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
decode(data: Uint8Array): string {
|
||||||
|
let str = ''
|
||||||
|
let i = 0
|
||||||
|
while (i < data.length) {
|
||||||
|
const byte1 = data[i++]
|
||||||
|
if (byte1 < 0x80) {
|
||||||
|
str += String.fromCharCode(byte1)
|
||||||
|
} else if ((byte1 & 0xe0) === 0xc0) {
|
||||||
|
const byte2 = data[i++]
|
||||||
|
str += String.fromCharCode(((byte1 & 0x1f) << 6) | (byte2 & 0x3f))
|
||||||
|
} else if ((byte1 & 0xf0) === 0xe0) {
|
||||||
|
const byte2 = data[i++]
|
||||||
|
const byte3 = data[i++]
|
||||||
|
str += String.fromCharCode(
|
||||||
|
((byte1 & 0x0f) << 12) | ((byte2 & 0x3f) << 6) | (byte3 & 0x3f)
|
||||||
|
)
|
||||||
|
} else if ((byte1 & 0xf8) === 0xf0) {
|
||||||
|
const byte2 = data[i++]
|
||||||
|
const byte3 = data[i++]
|
||||||
|
const byte4 = data[i++]
|
||||||
|
const codePoint =
|
||||||
|
((byte1 & 0x07) << 18) |
|
||||||
|
((byte2 & 0x3f) << 12) |
|
||||||
|
((byte3 & 0x3f) << 6) |
|
||||||
|
(byte4 & 0x3f)
|
||||||
|
const offset = codePoint - 0x10000
|
||||||
|
str += String.fromCharCode(
|
||||||
|
0xd800 + (offset >> 10),
|
||||||
|
0xdc00 + (offset & 0x3ff)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = getTextEncoder()
|
||||||
|
const decoder = getTextDecoder()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 将字符串编码为 UTF-8 字节数组
|
||||||
|
* @en Encode string to UTF-8 byte array
|
||||||
|
*/
|
||||||
|
export function textEncode(str: string): Uint8Array {
|
||||||
|
return encoder.encode(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 将 UTF-8 字节数组解码为字符串
|
||||||
|
* @en Decode UTF-8 byte array to string
|
||||||
|
*/
|
||||||
|
export function textDecode(data: Uint8Array): string {
|
||||||
|
return decoder.decode(data)
|
||||||
|
}
|
||||||
24
packages/framework/rpc/src/codec/types.ts
Normal file
24
packages/framework/rpc/src/codec/types.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* @zh 编解码器类型定义
|
||||||
|
* @en Codec Type Definitions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Packet } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 编解码器接口
|
||||||
|
* @en Codec interface
|
||||||
|
*/
|
||||||
|
export interface Codec {
|
||||||
|
/**
|
||||||
|
* @zh 编码数据包
|
||||||
|
* @en Encode packet
|
||||||
|
*/
|
||||||
|
encode(packet: Packet): string | Uint8Array
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 解码数据包
|
||||||
|
* @en Decode packet
|
||||||
|
*/
|
||||||
|
decode(data: string | Uint8Array): Packet
|
||||||
|
}
|
||||||
63
packages/framework/rpc/src/define.ts
Normal file
63
packages/framework/rpc/src/define.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* @zh 协议定义模块
|
||||||
|
* @en Protocol Definition Module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ApiDef, MsgDef, ProtocolDef } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建 API 定义
|
||||||
|
* @en Create API definition
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const join = rpc.api<{ name: string }, { id: string }>()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function api<TInput = void, TOutput = void>(): ApiDef<TInput, TOutput> {
|
||||||
|
return { _type: 'api' } as ApiDef<TInput, TOutput>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建消息定义
|
||||||
|
* @en Create message definition
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const chat = rpc.msg<{ from: string; text: string }>()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function msg<TData = void>(): MsgDef<TData> {
|
||||||
|
return { _type: 'msg' } as MsgDef<TData>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 定义协议
|
||||||
|
* @en Define protocol
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* export const protocol = rpc.define({
|
||||||
|
* api: {
|
||||||
|
* join: rpc.api<{ name: string }, { id: string }>(),
|
||||||
|
* leave: rpc.api<void, void>(),
|
||||||
|
* },
|
||||||
|
* msg: {
|
||||||
|
* chat: rpc.msg<{ from: string; text: string }>(),
|
||||||
|
* },
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function define<T extends ProtocolDef>(protocol: T): T {
|
||||||
|
return protocol
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh RPC 协议定义工具
|
||||||
|
* @en RPC protocol definition utilities
|
||||||
|
*/
|
||||||
|
export const rpc = {
|
||||||
|
define,
|
||||||
|
api,
|
||||||
|
msg,
|
||||||
|
} as const
|
||||||
42
packages/framework/rpc/src/index.ts
Normal file
42
packages/framework/rpc/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* @zh ESEngine RPC 库
|
||||||
|
* @en ESEngine RPC Library
|
||||||
|
*
|
||||||
|
* @zh 类型安全的 RPC 通信库,支持 WebSocket 长连接
|
||||||
|
* @en Type-safe RPC communication library with WebSocket support
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // 1. 定义协议(共享)
|
||||||
|
* import { rpc } from '@esengine/rpc'
|
||||||
|
*
|
||||||
|
* export const protocol = rpc.define({
|
||||||
|
* api: {
|
||||||
|
* join: rpc.api<{ name: string }, { id: string }>(),
|
||||||
|
* },
|
||||||
|
* msg: {
|
||||||
|
* chat: rpc.msg<{ from: string; text: string }>(),
|
||||||
|
* },
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // 2. 服务端
|
||||||
|
* import { serve } from '@esengine/rpc/server'
|
||||||
|
*
|
||||||
|
* const server = serve(protocol, {
|
||||||
|
* port: 3000,
|
||||||
|
* api: {
|
||||||
|
* join: async (input, conn) => ({ id: conn.id }),
|
||||||
|
* },
|
||||||
|
* })
|
||||||
|
* await server.start()
|
||||||
|
*
|
||||||
|
* // 3. 客户端
|
||||||
|
* import { connect } from '@esengine/rpc/client'
|
||||||
|
*
|
||||||
|
* const client = await connect(protocol, 'ws://localhost:3000')
|
||||||
|
* const result = await client.call('join', { name: 'Alice' })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { rpc } from './define'
|
||||||
|
export * from './types'
|
||||||
68
packages/framework/rpc/src/server/connection.ts
Normal file
68
packages/framework/rpc/src/server/connection.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* @zh 服务端连接模块
|
||||||
|
* @en Server Connection Module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Connection, ConnectionStatus } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 服务端连接实现
|
||||||
|
* @en Server connection implementation
|
||||||
|
*/
|
||||||
|
export class ServerConnection<TData = unknown> implements Connection<TData> {
|
||||||
|
readonly id: string
|
||||||
|
readonly ip: string
|
||||||
|
data: TData
|
||||||
|
|
||||||
|
private _status: ConnectionStatus = 'open'
|
||||||
|
private _socket: any
|
||||||
|
private _onClose?: () => void
|
||||||
|
|
||||||
|
constructor(options: {
|
||||||
|
id: string
|
||||||
|
ip: string
|
||||||
|
socket: any
|
||||||
|
initialData: TData
|
||||||
|
onClose?: () => void
|
||||||
|
}) {
|
||||||
|
this.id = options.id
|
||||||
|
this.ip = options.ip
|
||||||
|
this.data = options.initialData
|
||||||
|
this._socket = options.socket
|
||||||
|
this._onClose = options.onClose
|
||||||
|
}
|
||||||
|
|
||||||
|
get status(): ConnectionStatus {
|
||||||
|
return this._status
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 发送原始数据
|
||||||
|
* @en Send raw data
|
||||||
|
*/
|
||||||
|
send(data: string | Uint8Array): void {
|
||||||
|
if (this._status !== 'open') return
|
||||||
|
this._socket.send(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 关闭连接
|
||||||
|
* @en Close connection
|
||||||
|
*/
|
||||||
|
close(reason?: string): void {
|
||||||
|
if (this._status !== 'open') return
|
||||||
|
|
||||||
|
this._status = 'closing'
|
||||||
|
this._socket.close(1000, reason)
|
||||||
|
this._status = 'closed'
|
||||||
|
this._onClose?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 标记连接已关闭(内部使用)
|
||||||
|
* @en Mark connection as closed (internal use)
|
||||||
|
*/
|
||||||
|
_markClosed(): void {
|
||||||
|
this._status = 'closed'
|
||||||
|
}
|
||||||
|
}
|
||||||
372
packages/framework/rpc/src/server/index.ts
Normal file
372
packages/framework/rpc/src/server/index.ts
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
/**
|
||||||
|
* @zh RPC 服务端模块
|
||||||
|
* @en RPC Server Module
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { WebSocketServer, WebSocket } from 'ws'
|
||||||
|
import type {
|
||||||
|
ProtocolDef,
|
||||||
|
ApiNames,
|
||||||
|
MsgNames,
|
||||||
|
ApiInput,
|
||||||
|
ApiOutput,
|
||||||
|
MsgData,
|
||||||
|
Packet,
|
||||||
|
PacketType,
|
||||||
|
Connection,
|
||||||
|
} from '../types'
|
||||||
|
import { RpcError, ErrorCode } from '../types'
|
||||||
|
import { json } from '../codec/json'
|
||||||
|
import type { Codec } from '../codec/types'
|
||||||
|
import { ServerConnection } from './connection'
|
||||||
|
|
||||||
|
// ============ Types ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh API 处理函数
|
||||||
|
* @en API handler function
|
||||||
|
*/
|
||||||
|
type ApiHandler<TInput, TOutput, TConnData> = (
|
||||||
|
input: TInput,
|
||||||
|
conn: Connection<TConnData>
|
||||||
|
) => TOutput | Promise<TOutput>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 消息处理函数
|
||||||
|
* @en Message handler function
|
||||||
|
*/
|
||||||
|
type MsgHandler<TData, TConnData> = (
|
||||||
|
data: TData,
|
||||||
|
conn: Connection<TConnData>
|
||||||
|
) => void | Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh API 处理器映射
|
||||||
|
* @en API handlers map
|
||||||
|
*/
|
||||||
|
type ApiHandlers<P extends ProtocolDef, TConnData> = {
|
||||||
|
[K in ApiNames<P>]: ApiHandler<
|
||||||
|
ApiInput<P['api'][K]>,
|
||||||
|
ApiOutput<P['api'][K]>,
|
||||||
|
TConnData
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 消息处理器映射
|
||||||
|
* @en Message handlers map
|
||||||
|
*/
|
||||||
|
type MsgHandlers<P extends ProtocolDef, TConnData> = {
|
||||||
|
[K in MsgNames<P>]?: MsgHandler<MsgData<P['msg'][K]>, TConnData>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 服务器配置
|
||||||
|
* @en Server options
|
||||||
|
*/
|
||||||
|
export interface ServeOptions<P extends ProtocolDef, TConnData = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 监听端口
|
||||||
|
* @en Listen port
|
||||||
|
*/
|
||||||
|
port: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh API 处理器
|
||||||
|
* @en API handlers
|
||||||
|
*/
|
||||||
|
api: ApiHandlers<P, TConnData>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 消息处理器
|
||||||
|
* @en Message handlers
|
||||||
|
*/
|
||||||
|
msg?: MsgHandlers<P, TConnData>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 编解码器
|
||||||
|
* @en Codec
|
||||||
|
* @defaultValue json()
|
||||||
|
*/
|
||||||
|
codec?: Codec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接初始数据工厂
|
||||||
|
* @en Connection initial data factory
|
||||||
|
*/
|
||||||
|
createConnData?: () => TConnData
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接建立回调
|
||||||
|
* @en Connection established callback
|
||||||
|
*/
|
||||||
|
onConnect?: (conn: Connection<TConnData>) => void | Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接断开回调
|
||||||
|
* @en Connection closed callback
|
||||||
|
*/
|
||||||
|
onDisconnect?: (conn: Connection<TConnData>, reason?: string) => void | Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 错误回调
|
||||||
|
* @en Error callback
|
||||||
|
*/
|
||||||
|
onError?: (error: Error, conn?: Connection<TConnData>) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 服务器启动回调
|
||||||
|
* @en Server started callback
|
||||||
|
*/
|
||||||
|
onStart?: (port: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh RPC 服务器实例
|
||||||
|
* @en RPC Server instance
|
||||||
|
*/
|
||||||
|
export interface RpcServer<P extends ProtocolDef, TConnData = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 启动服务器
|
||||||
|
* @en Start server
|
||||||
|
*/
|
||||||
|
start(): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 停止服务器
|
||||||
|
* @en Stop server
|
||||||
|
*/
|
||||||
|
stop(): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取所有连接
|
||||||
|
* @en Get all connections
|
||||||
|
*/
|
||||||
|
readonly connections: ReadonlyArray<Connection<TConnData>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 向单个连接发送消息
|
||||||
|
* @en Send message to a single connection
|
||||||
|
*/
|
||||||
|
send<K extends MsgNames<P>>(
|
||||||
|
conn: Connection<TConnData>,
|
||||||
|
name: K,
|
||||||
|
data: MsgData<P['msg'][K]>
|
||||||
|
): void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 广播消息给所有连接
|
||||||
|
* @en Broadcast message to all connections
|
||||||
|
*/
|
||||||
|
broadcast<K extends MsgNames<P>>(
|
||||||
|
name: K,
|
||||||
|
data: MsgData<P['msg'][K]>,
|
||||||
|
options?: { exclude?: Connection<TConnData> | Connection<TConnData>[] }
|
||||||
|
): void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Implementation ============
|
||||||
|
|
||||||
|
const PT = {
|
||||||
|
ApiRequest: 0,
|
||||||
|
ApiResponse: 1,
|
||||||
|
ApiError: 2,
|
||||||
|
Message: 3,
|
||||||
|
Heartbeat: 9,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建 RPC 服务器
|
||||||
|
* @en Create RPC server
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const server = serve(protocol, {
|
||||||
|
* port: 3000,
|
||||||
|
* api: {
|
||||||
|
* join: async (input, conn) => {
|
||||||
|
* return { id: conn.id }
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* })
|
||||||
|
* await server.start()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function serve<P extends ProtocolDef, TConnData = unknown>(
|
||||||
|
_protocol: P,
|
||||||
|
options: ServeOptions<P, TConnData>
|
||||||
|
): RpcServer<P, TConnData> {
|
||||||
|
const codec = options.codec ?? json()
|
||||||
|
const connections: ServerConnection<TConnData>[] = []
|
||||||
|
let wss: WebSocketServer | null = null
|
||||||
|
let connIdCounter = 0
|
||||||
|
|
||||||
|
const getClientIp = (ws: WebSocket, req: any): string => {
|
||||||
|
return req?.headers?.['x-forwarded-for']?.split(',')[0]?.trim()
|
||||||
|
|| req?.socket?.remoteAddress
|
||||||
|
|| 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMessage = async (
|
||||||
|
conn: ServerConnection<TConnData>,
|
||||||
|
data: string | Buffer
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const packet = codec.decode(
|
||||||
|
typeof data === 'string' ? data : new Uint8Array(data)
|
||||||
|
)
|
||||||
|
|
||||||
|
const type = packet[0]
|
||||||
|
|
||||||
|
if (type === PT.ApiRequest) {
|
||||||
|
const [, id, path, input] = packet as [number, number, string, unknown]
|
||||||
|
await handleApiRequest(conn, id, path, input)
|
||||||
|
} else if (type === PT.Message) {
|
||||||
|
const [, path, msgData] = packet as [number, string, unknown]
|
||||||
|
await handleMsg(conn, path, msgData)
|
||||||
|
} else if (type === PT.Heartbeat) {
|
||||||
|
conn.send(codec.encode([PT.Heartbeat]))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
options.onError?.(err as Error, conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApiRequest = async (
|
||||||
|
conn: ServerConnection<TConnData>,
|
||||||
|
id: number,
|
||||||
|
path: string,
|
||||||
|
input: unknown
|
||||||
|
): Promise<void> => {
|
||||||
|
const handler = (options.api as any)[path]
|
||||||
|
|
||||||
|
if (!handler) {
|
||||||
|
const errPacket: Packet = [PT.ApiError, id, ErrorCode.NOT_FOUND, `API not found: ${path}`]
|
||||||
|
conn.send(codec.encode(errPacket))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await handler(input, conn)
|
||||||
|
const resPacket: Packet = [PT.ApiResponse, id, result]
|
||||||
|
conn.send(codec.encode(resPacket))
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RpcError) {
|
||||||
|
const errPacket: Packet = [PT.ApiError, id, err.code, err.message]
|
||||||
|
conn.send(codec.encode(errPacket))
|
||||||
|
} else {
|
||||||
|
const errPacket: Packet = [PT.ApiError, id, ErrorCode.INTERNAL_ERROR, 'Internal server error']
|
||||||
|
conn.send(codec.encode(errPacket))
|
||||||
|
options.onError?.(err as Error, conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMsg = async (
|
||||||
|
conn: ServerConnection<TConnData>,
|
||||||
|
path: string,
|
||||||
|
data: unknown
|
||||||
|
): Promise<void> => {
|
||||||
|
const handler = options.msg?.[path as MsgNames<P>]
|
||||||
|
if (handler) {
|
||||||
|
await (handler as any)(data, conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server: RpcServer<P, TConnData> = {
|
||||||
|
get connections() {
|
||||||
|
return connections as ReadonlyArray<Connection<TConnData>>
|
||||||
|
},
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
wss = new WebSocketServer({ port: options.port })
|
||||||
|
|
||||||
|
wss.on('connection', async (ws, req) => {
|
||||||
|
const id = String(++connIdCounter)
|
||||||
|
const ip = getClientIp(ws, req)
|
||||||
|
const initialData = options.createConnData?.() ?? ({} as TConnData)
|
||||||
|
|
||||||
|
const conn = new ServerConnection<TConnData>({
|
||||||
|
id,
|
||||||
|
ip,
|
||||||
|
socket: ws,
|
||||||
|
initialData,
|
||||||
|
onClose: () => {
|
||||||
|
const idx = connections.indexOf(conn)
|
||||||
|
if (idx !== -1) connections.splice(idx, 1)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
connections.push(conn)
|
||||||
|
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
handleMessage(conn, data as string | Buffer)
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('close', async (code, reason) => {
|
||||||
|
conn._markClosed()
|
||||||
|
const idx = connections.indexOf(conn)
|
||||||
|
if (idx !== -1) connections.splice(idx, 1)
|
||||||
|
await options.onDisconnect?.(conn, reason?.toString())
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
options.onError?.(err, conn)
|
||||||
|
})
|
||||||
|
|
||||||
|
await options.onConnect?.(conn)
|
||||||
|
})
|
||||||
|
|
||||||
|
wss.on('listening', () => {
|
||||||
|
options.onStart?.(options.port)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!wss) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const conn of connections) {
|
||||||
|
conn.close('Server shutting down')
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.close((err) => {
|
||||||
|
if (err) reject(err)
|
||||||
|
else resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
send(conn, name, data) {
|
||||||
|
const packet: Packet = [PT.Message, name as string, data]
|
||||||
|
;(conn as ServerConnection<TConnData>).send(codec.encode(packet))
|
||||||
|
},
|
||||||
|
|
||||||
|
broadcast(name, data, opts) {
|
||||||
|
const packet: Packet = [PT.Message, name as string, data]
|
||||||
|
const encoded = codec.encode(packet)
|
||||||
|
|
||||||
|
const excludeSet = new Set(
|
||||||
|
Array.isArray(opts?.exclude)
|
||||||
|
? opts.exclude
|
||||||
|
: opts?.exclude
|
||||||
|
? [opts.exclude]
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const conn of connections) {
|
||||||
|
if (!excludeSet.has(conn)) {
|
||||||
|
conn.send(encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
217
packages/framework/rpc/src/types.ts
Normal file
217
packages/framework/rpc/src/types.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* @zh RPC 核心类型定义
|
||||||
|
* @en RPC Core Type Definitions
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============ Protocol Types ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh API 定义标记
|
||||||
|
* @en API definition marker
|
||||||
|
*/
|
||||||
|
export interface ApiDef<TInput = unknown, TOutput = unknown> {
|
||||||
|
readonly _type: 'api'
|
||||||
|
readonly _input: TInput
|
||||||
|
readonly _output: TOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 消息定义标记
|
||||||
|
* @en Message definition marker
|
||||||
|
*/
|
||||||
|
export interface MsgDef<TData = unknown> {
|
||||||
|
readonly _type: 'msg'
|
||||||
|
readonly _data: TData
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 协议定义
|
||||||
|
* @en Protocol definition
|
||||||
|
*/
|
||||||
|
export interface ProtocolDef {
|
||||||
|
readonly api: Record<string, ApiDef<any, any>>
|
||||||
|
readonly msg: Record<string, MsgDef<any>>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Type Inference ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 提取 API 输入类型
|
||||||
|
* @en Extract API input type
|
||||||
|
*/
|
||||||
|
export type ApiInput<T> = T extends ApiDef<infer I, any> ? I : never
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 提取 API 输出类型
|
||||||
|
* @en Extract API output type
|
||||||
|
*/
|
||||||
|
export type ApiOutput<T> = T extends ApiDef<any, infer O> ? O : never
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 提取消息数据类型
|
||||||
|
* @en Extract message data type
|
||||||
|
*/
|
||||||
|
export type MsgData<T> = T extends MsgDef<infer D> ? D : never
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 提取协议中所有 API 名称
|
||||||
|
* @en Extract all API names from protocol
|
||||||
|
*/
|
||||||
|
export type ApiNames<P extends ProtocolDef> = keyof P['api'] & string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 提取协议中所有消息名称
|
||||||
|
* @en Extract all message names from protocol
|
||||||
|
*/
|
||||||
|
export type MsgNames<P extends ProtocolDef> = keyof P['msg'] & string
|
||||||
|
|
||||||
|
// ============ Connection Types ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接状态
|
||||||
|
* @en Connection status
|
||||||
|
*/
|
||||||
|
export type ConnectionStatus = 'connecting' | 'open' | 'closing' | 'closed'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接接口
|
||||||
|
* @en Connection interface
|
||||||
|
*/
|
||||||
|
export interface Connection<TData = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 连接唯一标识
|
||||||
|
* @en Connection unique identifier
|
||||||
|
*/
|
||||||
|
readonly id: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 客户端 IP 地址
|
||||||
|
* @en Client IP address
|
||||||
|
*/
|
||||||
|
readonly ip: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接状态
|
||||||
|
* @en Connection status
|
||||||
|
*/
|
||||||
|
readonly status: ConnectionStatus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户自定义数据
|
||||||
|
* @en User-defined data
|
||||||
|
*/
|
||||||
|
data: TData
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 关闭连接
|
||||||
|
* @en Close connection
|
||||||
|
*/
|
||||||
|
close(reason?: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Packet Types ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 数据包类型
|
||||||
|
* @en Packet types
|
||||||
|
*/
|
||||||
|
export const PacketType = {
|
||||||
|
ApiRequest: 0,
|
||||||
|
ApiResponse: 1,
|
||||||
|
ApiError: 2,
|
||||||
|
Message: 3,
|
||||||
|
Heartbeat: 9,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PacketType = typeof PacketType[keyof typeof PacketType]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh API 请求包
|
||||||
|
* @en API request packet
|
||||||
|
*/
|
||||||
|
export type ApiRequestPacket = [
|
||||||
|
type: typeof PacketType.ApiRequest,
|
||||||
|
id: number,
|
||||||
|
path: string,
|
||||||
|
data: unknown
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh API 响应包(成功)
|
||||||
|
* @en API response packet (success)
|
||||||
|
*/
|
||||||
|
export type ApiResponsePacket = [
|
||||||
|
type: typeof PacketType.ApiResponse,
|
||||||
|
id: number,
|
||||||
|
data: unknown
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh API 错误包
|
||||||
|
* @en API error packet
|
||||||
|
*/
|
||||||
|
export type ApiErrorPacket = [
|
||||||
|
type: typeof PacketType.ApiError,
|
||||||
|
id: number,
|
||||||
|
code: string,
|
||||||
|
message: string
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 消息包
|
||||||
|
* @en Message packet
|
||||||
|
*/
|
||||||
|
export type MessagePacket = [
|
||||||
|
type: typeof PacketType.Message,
|
||||||
|
path: string,
|
||||||
|
data: unknown
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 心跳包
|
||||||
|
* @en Heartbeat packet
|
||||||
|
*/
|
||||||
|
export type HeartbeatPacket = [type: typeof PacketType.Heartbeat]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 所有数据包类型
|
||||||
|
* @en All packet types
|
||||||
|
*/
|
||||||
|
export type Packet =
|
||||||
|
| ApiRequestPacket
|
||||||
|
| ApiResponsePacket
|
||||||
|
| ApiErrorPacket
|
||||||
|
| MessagePacket
|
||||||
|
| HeartbeatPacket
|
||||||
|
|
||||||
|
// ============ Error Types ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh RPC 错误
|
||||||
|
* @en RPC Error
|
||||||
|
*/
|
||||||
|
export class RpcError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code: string,
|
||||||
|
message: string,
|
||||||
|
public readonly details?: unknown
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'RpcError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 预定义错误码
|
||||||
|
* @en Predefined error codes
|
||||||
|
*/
|
||||||
|
export const ErrorCode = {
|
||||||
|
INVALID_REQUEST: 'INVALID_REQUEST',
|
||||||
|
NOT_FOUND: 'NOT_FOUND',
|
||||||
|
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||||
|
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
||||||
|
TIMEOUT: 'TIMEOUT',
|
||||||
|
CONNECTION_CLOSED: 'CONNECTION_CLOSED',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ErrorCode = typeof ErrorCode[keyof typeof ErrorCode]
|
||||||
11
packages/framework/rpc/tsconfig.json
Normal file
11
packages/framework/rpc/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
15
packages/framework/rpc/tsup.config.ts
Normal file
15
packages/framework/rpc/tsup.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'tsup'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: {
|
||||||
|
index: 'src/index.ts',
|
||||||
|
'server/index': 'src/server/index.ts',
|
||||||
|
'client/index': 'src/client/index.ts',
|
||||||
|
'codec/index': 'src/codec/index.ts',
|
||||||
|
},
|
||||||
|
format: ['esm'],
|
||||||
|
dts: false,
|
||||||
|
clean: true,
|
||||||
|
sourcemap: true,
|
||||||
|
external: ['ws', 'msgpackr'],
|
||||||
|
})
|
||||||
@@ -12,13 +12,16 @@ export const nodejsAdapter: PlatformAdapter = {
|
|||||||
getDependencies() {
|
getDependencies() {
|
||||||
return {
|
return {
|
||||||
'@esengine/ecs-framework': 'latest',
|
'@esengine/ecs-framework': 'latest',
|
||||||
'@esengine/network-server': 'latest'
|
'@esengine/rpc': 'latest',
|
||||||
|
'@esengine/network': 'latest',
|
||||||
|
'ws': '^8.18.0'
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
getDevDependencies() {
|
getDevDependencies() {
|
||||||
return {
|
return {
|
||||||
'@types/node': '^20.0.0',
|
'@types/node': '^20.0.0',
|
||||||
|
'@types/ws': '^8.5.13',
|
||||||
'tsx': '^4.0.0',
|
'tsx': '^4.0.0',
|
||||||
'typescript': '^5.0.0'
|
'typescript': '^5.0.0'
|
||||||
};
|
};
|
||||||
@@ -54,7 +57,7 @@ function generateIndex(config: ProjectConfig): string {
|
|||||||
const PORT = Number(process.env.PORT) || 3000;
|
const PORT = Number(process.env.PORT) || 3000;
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const server = createGameServer({ port: PORT });
|
const { server } = createGameServer({ port: PORT });
|
||||||
await server.start();
|
await server.start();
|
||||||
|
|
||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
@@ -75,28 +78,73 @@ main().catch(console.error);
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generateGameServer(config: ProjectConfig): string {
|
function generateGameServer(config: ProjectConfig): string {
|
||||||
return `import { GameServer, type IServerConfig } from '@esengine/network-server';
|
return `import { RpcServer } from '@esengine/rpc/server';
|
||||||
|
import { gameProtocol, type JoinRequest, type JoinResponse } from '@esengine/network';
|
||||||
import { Game } from '../game/Game';
|
import { Game } from '../game/Game';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 服务器配置
|
||||||
|
* @en Server configuration
|
||||||
|
*/
|
||||||
|
export interface ServerConfig {
|
||||||
|
port: number;
|
||||||
|
maxPlayers?: number;
|
||||||
|
tickRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 创建游戏服务器
|
* @zh 创建游戏服务器
|
||||||
* @en Create game server
|
* @en Create game server
|
||||||
*/
|
*/
|
||||||
export function createGameServer(config: Partial<IServerConfig> = {}): GameServer {
|
export function createGameServer(config: Partial<ServerConfig> = {}) {
|
||||||
const server = new GameServer({
|
const port = config.port ?? 3000;
|
||||||
port: config.port ?? 3000,
|
const maxPlayers = config.maxPlayers ?? 16;
|
||||||
roomConfig: {
|
const tickRate = config.tickRate ?? 20;
|
||||||
maxPlayers: 16,
|
|
||||||
tickRate: 20,
|
// 创建 RPC 服务器
|
||||||
...config.roomConfig
|
const server = new RpcServer(gameProtocol, {
|
||||||
|
port,
|
||||||
|
onStart: (p) => console.log(\`[Server] Started on ws://localhost:\${p}\`),
|
||||||
|
onConnection: (id) => console.log(\`[Server] Client connected: \${id}\`),
|
||||||
|
onDisconnection: (id) => console.log(\`[Server] Client disconnected: \${id}\`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 玩家管理
|
||||||
|
const players = new Map<string, { id: number; name: string }>();
|
||||||
|
let nextPlayerId = 1;
|
||||||
|
|
||||||
|
// 注册 API 处理器
|
||||||
|
server.handle('join', async (input: JoinRequest, ctx): Promise<JoinResponse> => {
|
||||||
|
const playerId = nextPlayerId++;
|
||||||
|
players.set(ctx.clientId, { id: playerId, name: input.playerName });
|
||||||
|
|
||||||
|
console.log(\`[Server] Player joined: \${input.playerName} (ID: \${playerId})\`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerId,
|
||||||
|
roomId: input.roomId ?? 'default',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
server.handle('leave', async (_input, ctx) => {
|
||||||
|
const player = players.get(ctx.clientId);
|
||||||
|
if (player) {
|
||||||
|
console.log(\`[Server] Player left: \${player.name}\`);
|
||||||
|
players.delete(ctx.clientId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 初始化 ECS 游戏逻辑
|
// 初始化 ECS 游戏逻辑
|
||||||
const game = new Game();
|
const game = new Game({ targetFPS: tickRate });
|
||||||
game.start();
|
game.start();
|
||||||
|
|
||||||
return server;
|
// 游戏循环:广播状态同步
|
||||||
|
setInterval(() => {
|
||||||
|
// 在这里广播游戏状态
|
||||||
|
// server.broadcast('sync', { entities: [...] });
|
||||||
|
}, 1000 / tickRate);
|
||||||
|
|
||||||
|
return { server, game, players };
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -319,18 +367,24 @@ src/
|
|||||||
## 客户端连接
|
## 客户端连接
|
||||||
|
|
||||||
\`\`\`typescript
|
\`\`\`typescript
|
||||||
|
import { Core } from '@esengine/ecs-framework';
|
||||||
import { NetworkPlugin } from '@esengine/network';
|
import { NetworkPlugin } from '@esengine/network';
|
||||||
|
|
||||||
const networkPlugin = new NetworkPlugin({
|
// 安装网络插件
|
||||||
serverUrl: 'ws://localhost:3000'
|
const networkPlugin = new NetworkPlugin();
|
||||||
});
|
await Core.installPlugin(networkPlugin);
|
||||||
|
|
||||||
await networkPlugin.connect('PlayerName');
|
// 连接服务器
|
||||||
|
await networkPlugin.connect({
|
||||||
|
url: 'ws://localhost:3000',
|
||||||
|
playerName: 'Player1'
|
||||||
|
});
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
- [ESEngine 文档](https://esengine.github.io/esengine/)
|
- [ESEngine 文档](https://esengine.github.io/esengine/)
|
||||||
|
- [RPC 模块](https://esengine.github.io/esengine/modules/rpc/)
|
||||||
- [Network 模块](https://esengine.github.io/esengine/modules/network/)
|
- [Network 模块](https://esengine.github.io/esengine/modules/network/)
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
530
pnpm-lock.yaml
generated
530
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user