feat(server): 添加游戏服务器框架 | add game server framework (#366)

**@esengine/server** - 游戏服务器框架 | Game server framework
- 文件路由系统 | File-based routing
- Room 生命周期 (onCreate, onJoin, onLeave, onTick, onDispose)
- @onMessage 装饰器 | Message handler decorator
- 玩家管理与断线处理 | Player management with auto-disconnect
- 内置 JoinRoom/LeaveRoom API | Built-in room APIs
- defineApi/defineMsg 类型安全辅助函数 | Type-safe helpers

**create-esengine-server** - CLI 脚手架工具 | CLI scaffolding
- 生成 shared/server/client 项目结构 | Project structure
- 类型安全的协议定义 | Type-safe protocol definitions
- 包含 GameRoom 示例 | Example implementation

**其他 | Other**
- 删除旧的 network-server 包 | Remove old network-server
- 更新服务器文档 | Update server documentation
This commit is contained in:
YHH
2025-12-28 12:23:55 +08:00
committed by GitHub
parent 41529f6fbb
commit b6f1235239
31 changed files with 2793 additions and 824 deletions

View File

@@ -1,76 +1,335 @@
---
title: "Server Side"
description: "GameServer and Room management"
description: "Build game servers with @esengine/server"
---
## GameServer
## Quick Start
GameServer is the core server-side class managing WebSocket connections and rooms.
Create a new game server project using the CLI:
### Basic Usage
```bash
# Using npm
npm create esengine-server my-game-server
# Using pnpm
pnpm create esengine-server my-game-server
# Using yarn
yarn create esengine-server my-game-server
```
Generated project structure:
```
my-game-server/
├── src/
│ ├── shared/ # Shared protocol (client & server)
│ │ ├── protocol.ts # Type definitions
│ │ └── index.ts
│ ├── server/ # Server code
│ │ ├── main.ts # Entry point
│ │ └── rooms/
│ │ └── GameRoom.ts # Game room
│ └── client/ # Client example
│ └── index.ts
├── package.json
└── tsconfig.json
```
Start the server:
```bash
# Development mode (hot reload)
npm run dev
# Production mode
npm run start
```
## createServer
Create a game server instance:
```typescript
import { GameServer } from '@esengine/network-server';
import { createServer } from '@esengine/server'
import { GameRoom } from './rooms/GameRoom.js'
const server = new GameServer({
const server = await createServer({
port: 3000,
roomConfig: {
maxPlayers: 16,
tickRate: 20
onConnect(conn) {
console.log('Client connected:', conn.id)
},
onDisconnect(conn) {
console.log('Client disconnected:', conn.id)
},
})
// Register room type
server.define('game', GameRoom)
// Start server
await server.start()
```
### Configuration Options
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `port` | `number` | `3000` | WebSocket port |
| `tickRate` | `number` | `20` | Global tick rate (Hz) |
| `apiDir` | `string` | `'src/api'` | API handlers directory |
| `msgDir` | `string` | `'src/msg'` | Message handlers directory |
| `onStart` | `(port) => void` | - | Start callback |
| `onConnect` | `(conn) => void` | - | Connection callback |
| `onDisconnect` | `(conn) => void` | - | Disconnect callback |
## Room System
Room is the base class for game rooms, managing players and game state.
### Define a Room
```typescript
import { Room, Player, onMessage } from '@esengine/server'
import type { MsgMove, MsgChat } from '../../shared/index.js'
interface PlayerData {
name: string
x: number
y: number
}
export class GameRoom extends Room<{ players: any[] }, PlayerData> {
// Configuration
maxPlayers = 8
tickRate = 20
autoDispose = true
// Room state
state = {
players: [],
}
});
await server.start();
await server.stop();
```
// Lifecycle
onCreate() {
console.log(`Room ${this.id} created`)
}
### Configuration
onJoin(player: Player<PlayerData>) {
player.data.name = 'Player_' + player.id.slice(-4)
player.data.x = Math.random() * 800
player.data.y = Math.random() * 600
| Property | Type | Description |
|----------|------|-------------|
| `port` | `number` | WebSocket port |
| `roomConfig.maxPlayers` | `number` | Max players per room |
| `roomConfig.tickRate` | `number` | Sync rate (Hz) |
this.broadcast('Joined', {
playerId: player.id,
playerName: player.data.name,
})
}
## Room
onLeave(player: Player<PlayerData>) {
this.broadcast('Left', { playerId: player.id })
}
```typescript
class Room {
readonly id: string;
readonly playerCount: number;
readonly isFull: boolean;
onTick(dt: number) {
// State synchronization
this.broadcast('Sync', { players: this.state.players })
}
addPlayer(name: string, connection: Connection): IPlayer | null;
removePlayer(clientId: number): void;
getPlayer(clientId: number): IPlayer | undefined;
handleInput(clientId: number, input: IPlayerInput): void;
destroy(): void;
onDispose() {
console.log(`Room ${this.id} disposed`)
}
// Message handlers
@onMessage('Move')
handleMove(data: MsgMove, player: Player<PlayerData>) {
player.data.x = data.x
player.data.y = data.y
}
@onMessage('Chat')
handleChat(data: MsgChat, player: Player<PlayerData>) {
this.broadcast('Chat', {
from: player.data.name,
text: data.text,
})
}
}
```
## Protocol Types
### Room Configuration
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `maxPlayers` | `number` | `10` | Maximum players |
| `tickRate` | `number` | `20` | Tick rate (Hz) |
| `autoDispose` | `boolean` | `true` | Auto-dispose empty rooms |
### Room API
```typescript
interface MsgSync {
time: number;
entities: IEntityState[];
class Room<TState, TPlayerData> {
readonly id: string // Room ID
readonly players: Player[] // All players
readonly playerCount: number // Player count
readonly isLocked: boolean // Lock status
state: TState // Room state
// Broadcast to all players
broadcast<T>(type: string, data: T): void
// Broadcast to all except one
broadcastExcept<T>(type: string, data: T, except: Player): void
// Get player by ID
getPlayer(id: string): Player | undefined
// Kick a player
kick(player: Player, reason?: string): void
// Lock/unlock room
lock(): void
unlock(): void
// Dispose room
dispose(): void
}
```
### Lifecycle Methods
| Method | Trigger | Purpose |
|--------|---------|---------|
| `onCreate()` | Room created | Initialize game state |
| `onJoin(player)` | Player joins | Welcome message, assign position |
| `onLeave(player)` | Player leaves | Cleanup player data |
| `onTick(dt)` | Every frame | Game logic, state sync |
| `onDispose()` | Before disposal | Save data, cleanup resources |
## Player Class
Player represents a connected player in a room.
```typescript
class Player<TData = Record<string, unknown>> {
readonly id: string // Player ID
readonly roomId: string // Room ID
data: TData // Custom data
// Send message to this player
send<T>(type: string, data: T): void
// Leave room
leave(): void
}
```
## @onMessage Decorator
Use decorators to simplify message handling:
```typescript
import { Room, Player, onMessage } from '@esengine/server'
class GameRoom extends Room {
@onMessage('Move')
handleMove(data: { x: number; y: number }, player: Player) {
// Handle movement
}
@onMessage('Attack')
handleAttack(data: { targetId: string }, player: Player) {
// Handle attack
}
}
```
## Protocol Definition
Define shared types in `src/shared/protocol.ts`:
```typescript
// API request/response
export interface JoinRoomReq {
roomType: string
playerName: string
}
interface MsgSpawn {
netId: number;
ownerId: number;
prefab: string;
pos: Vec2;
rot: number;
export interface JoinRoomRes {
roomId: string
playerId: string
}
interface MsgDespawn {
netId: number;
// Game messages
export interface MsgMove {
x: number
y: number
}
export interface MsgChat {
text: string
}
// Server broadcasts
export interface BroadcastSync {
players: PlayerState[]
}
export interface PlayerState {
id: string
name: string
x: number
y: number
}
```
## Client Connection
```typescript
import { connect } from '@esengine/rpc/client'
const client = await connect('ws://localhost:3000')
// Join room
const { roomId, playerId } = await client.call('JoinRoom', {
roomType: 'game',
playerName: 'Alice',
})
// Listen for broadcasts
client.onMessage('Sync', (data) => {
console.log('State:', data.players)
})
client.onMessage('Joined', (data) => {
console.log('Player joined:', data.playerName)
})
// Send message
client.send('RoomMessage', {
type: 'Move',
payload: { x: 100, y: 200 },
})
```
## Best Practices
1. **Set appropriate tick rate**: Choose based on game type (20-60 Hz for action games)
2. **Room size control**: Set reasonable `maxPlayers` based on server capacity
3. **State validation**: Server should validate client inputs to prevent cheating
1. **Set Appropriate Tick Rate**
- Turn-based games: 5-10 Hz
- Casual games: 10-20 Hz
- Action games: 20-60 Hz
2. **Use Shared Protocol**
- Define all types in `shared/` directory
- Import from here in both client and server
3. **State Validation**
- Server should validate all client inputs
- Never trust client-sent data
4. **Disconnect Handling**
- Implement reconnection logic
- Use `onLeave` to save player state
5. **Room Lifecycle**
- Use `autoDispose` to clean up empty rooms
- Save important data in `onDispose`

View File

@@ -1,164 +1,21 @@
---
title: "服务器端"
description: "GameServer 和 Room 管理"
description: "使用 @esengine/server 构建游戏服务器"
---
## GameServer
## 快速开始
GameServer 是服务器端的核心类,管理 WebSocket 连接和房间。
### 基本用法
```typescript
import { GameServer } from '@esengine/network-server';
const server = new GameServer({
port: 3000,
roomConfig: {
maxPlayers: 16,
tickRate: 20
}
});
// 启动服务器
await server.start();
console.log('Server started on ws://localhost:3000');
// 停止服务器
await server.stop();
```
### 配置选项
| 属性 | 类型 | 描述 |
|------|------|------|
| `port` | `number` | WebSocket 端口 |
| `roomConfig.maxPlayers` | `number` | 房间最大玩家数 |
| `roomConfig.tickRate` | `number` | 同步频率 (Hz) |
### 房间管理
```typescript
// 获取或创建房间
const room = server.getOrCreateRoom('room-id');
// 获取已存在的房间
const existingRoom = server.getRoom('room-id');
// 销毁房间
server.destroyRoom('room-id');
```
## Room
Room 类管理单个游戏房间的玩家和状态。
### API
```typescript
class Room {
readonly id: string;
readonly playerCount: number;
readonly isFull: boolean;
// 添加玩家
addPlayer(name: string, connection: Connection): IPlayer | null;
// 移除玩家
removePlayer(clientId: number): void;
// 获取玩家
getPlayer(clientId: number): IPlayer | undefined;
// 处理输入
handleInput(clientId: number, input: IPlayerInput): void;
// 销毁房间
destroy(): void;
}
```
### 玩家接口
```typescript
interface IPlayer {
clientId: number; // 客户端 ID
name: string; // 玩家名称
connection: Connection; // 连接对象
netId: number; // 网络实体 ID
}
```
## 协议类型
### 消息类型
```typescript
// 状态同步消息
interface MsgSync {
time: number;
entities: IEntityState[];
}
// 实体状态
interface IEntityState {
netId: number;
pos?: Vec2;
rot?: number;
}
// 生成消息
interface MsgSpawn {
netId: number;
ownerId: number;
prefab: string;
pos: Vec2;
rot: number;
}
// 销毁消息
interface MsgDespawn {
netId: number;
}
// 输入消息
interface MsgInput {
input: IPlayerInput;
}
// 玩家输入
interface IPlayerInput {
seq?: number;
moveDir?: Vec2;
actions?: string[];
}
```
### API 类型
```typescript
// 加入请求
interface ReqJoin {
playerName: string;
roomId?: string;
}
// 加入响应
interface ResJoin {
clientId: number;
roomId: string;
playerCount: number;
}
```
## 使用 CLI 创建服务端
推荐使用 ESEngine CLI 快速创建完整的游戏服务端:
使用 CLI 创建新的游戏服务器项目:
```bash
mkdir my-game-server && cd my-game-server
npm init -y
npx @esengine/cli init -p nodejs
# 使用 npm
npm create esengine-server my-game-server
# 使用 pnpm
pnpm create esengine-server my-game-server
# 使用 yarn
yarn create esengine-server my-game-server
```
生成的项目结构:
@@ -166,24 +23,20 @@ npx @esengine/cli init -p nodejs
```
my-game-server/
├── src/
│ ├── index.ts # 入口文件
│ ├── server/
│ │ └── GameServer.ts # 网络服务器配置
── game/
├── Game.ts # ECS 游戏主类
── scenes/
└── MainScene.ts # 主场景
├── components/ # ECS 组件
│ ├── PositionComponent.ts
│ │ └── VelocityComponent.ts
│ └── systems/ # ECS 系统
│ └── MovementSystem.ts
├── tsconfig.json
│ ├── shared/ # 共享协议(客户端服务端通用)
│ ├── protocol.ts # 类型定义
│ │ └── index.ts
── server/ # 服务端代码
├── main.ts # 入口
── rooms/
└── GameRoom.ts # 游戏房间
└── client/ # 客户端示例
└── index.ts
├── package.json
└── README.md
└── tsconfig.json
```
启动服务
启动服务
```bash
# 开发模式(热重载)
@@ -193,15 +46,290 @@ npm run dev
npm run start
```
## createServer
创建游戏服务器实例:
```typescript
import { createServer } from '@esengine/server'
import { GameRoom } from './rooms/GameRoom.js'
const server = await createServer({
port: 3000,
onConnect(conn) {
console.log('Client connected:', conn.id)
},
onDisconnect(conn) {
console.log('Client disconnected:', conn.id)
},
})
// 注册房间类型
server.define('game', GameRoom)
// 启动服务器
await server.start()
```
### 配置选项
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `port` | `number` | `3000` | WebSocket 端口 |
| `tickRate` | `number` | `20` | 全局 Tick 频率 (Hz) |
| `apiDir` | `string` | `'src/api'` | API 处理器目录 |
| `msgDir` | `string` | `'src/msg'` | 消息处理器目录 |
| `onStart` | `(port) => void` | - | 启动回调 |
| `onConnect` | `(conn) => void` | - | 连接回调 |
| `onDisconnect` | `(conn) => void` | - | 断开回调 |
## Room 系统
Room 是游戏房间的基类,管理玩家和游戏状态。
### 定义房间
```typescript
import { Room, Player, onMessage } from '@esengine/server'
import type { MsgMove, MsgChat } from '../../shared/index.js'
interface PlayerData {
name: string
x: number
y: number
}
export class GameRoom extends Room<{ players: any[] }, PlayerData> {
// 配置
maxPlayers = 8
tickRate = 20
autoDispose = true
// 房间状态
state = {
players: [],
}
// 生命周期
onCreate() {
console.log(`Room ${this.id} created`)
}
onJoin(player: Player<PlayerData>) {
player.data.name = 'Player_' + player.id.slice(-4)
player.data.x = Math.random() * 800
player.data.y = Math.random() * 600
this.broadcast('Joined', {
playerId: player.id,
playerName: player.data.name,
})
}
onLeave(player: Player<PlayerData>) {
this.broadcast('Left', { playerId: player.id })
}
onTick(dt: number) {
// 状态同步
this.broadcast('Sync', { players: this.state.players })
}
onDispose() {
console.log(`Room ${this.id} disposed`)
}
// 消息处理
@onMessage('Move')
handleMove(data: MsgMove, player: Player<PlayerData>) {
player.data.x = data.x
player.data.y = data.y
}
@onMessage('Chat')
handleChat(data: MsgChat, player: Player<PlayerData>) {
this.broadcast('Chat', {
from: player.data.name,
text: data.text,
})
}
}
```
### Room 配置
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `maxPlayers` | `number` | `10` | 最大玩家数 |
| `tickRate` | `number` | `20` | Tick 频率 (Hz) |
| `autoDispose` | `boolean` | `true` | 空房间自动销毁 |
### Room API
```typescript
class Room<TState, TPlayerData> {
readonly id: string // 房间 ID
readonly players: Player[] // 所有玩家
readonly playerCount: number // 玩家数量
readonly isLocked: boolean // 是否锁定
state: TState // 房间状态
// 广播消息给所有玩家
broadcast<T>(type: string, data: T): void
// 广播消息给除某玩家外的所有人
broadcastExcept<T>(type: string, data: T, except: Player): void
// 获取玩家
getPlayer(id: string): Player | undefined
// 踢出玩家
kick(player: Player, reason?: string): void
// 锁定/解锁房间
lock(): void
unlock(): void
// 销毁房间
dispose(): void
}
```
### 生命周期方法
| 方法 | 触发时机 | 用途 |
|------|----------|------|
| `onCreate()` | 房间创建时 | 初始化游戏状态 |
| `onJoin(player)` | 玩家加入时 | 欢迎消息、分配位置 |
| `onLeave(player)` | 玩家离开时 | 清理玩家数据 |
| `onTick(dt)` | 每帧调用 | 游戏逻辑、状态同步 |
| `onDispose()` | 房间销毁前 | 保存数据、清理资源 |
## Player 类
Player 代表房间中的一个玩家连接。
```typescript
class Player<TData = Record<string, unknown>> {
readonly id: string // 玩家 ID
readonly roomId: string // 所在房间 ID
data: TData // 自定义数据
// 发送消息给此玩家
send<T>(type: string, data: T): void
// 离开房间
leave(): void
}
```
## @onMessage 装饰器
使用装饰器简化消息处理:
```typescript
import { Room, Player, onMessage } from '@esengine/server'
class GameRoom extends Room {
@onMessage('Move')
handleMove(data: { x: number; y: number }, player: Player) {
// 处理移动
}
@onMessage('Attack')
handleAttack(data: { targetId: string }, player: Player) {
// 处理攻击
}
}
```
## 协议定义
`src/shared/protocol.ts` 中定义客户端和服务端共享的类型:
```typescript
// API 请求/响应
export interface JoinRoomReq {
roomType: string
playerName: string
}
export interface JoinRoomRes {
roomId: string
playerId: string
}
// 游戏消息
export interface MsgMove {
x: number
y: number
}
export interface MsgChat {
text: string
}
// 服务端广播
export interface BroadcastSync {
players: PlayerState[]
}
export interface PlayerState {
id: string
name: string
x: number
y: number
}
```
## 客户端连接
```typescript
import { connect } from '@esengine/rpc/client'
const client = await connect('ws://localhost:3000')
// 加入房间
const { roomId, playerId } = await client.call('JoinRoom', {
roomType: 'game',
playerName: 'Alice',
})
// 监听广播
client.onMessage('Sync', (data) => {
console.log('State:', data.players)
})
client.onMessage('Joined', (data) => {
console.log('Player joined:', data.playerName)
})
// 发送消息
client.send('RoomMessage', {
type: 'Move',
payload: { x: 100, y: 200 },
})
```
## 最佳实践
1. **合理设置同步频率**:根据游戏类型选择合适的 `tickRate`
1. **合理设置 Tick 频率**
- 回合制游戏5-10 Hz
- 休闲游戏10-20 Hz
- 动作游戏20-60 Hz
2. **房间大小控制**:根据服务器性能设置合理的 `maxPlayers`
2. **使用共享协议**
-`shared/` 目录定义所有类型
- 客户端和服务端都从这里导入
3. **连接管理**:监听玩家连接/断开事件,处理异常情况
3. **状态验证**
- 服务器应验证客户端输入
- 不信任客户端发送的任何数据
4. **状态验证**:服务器应验证客户端输入,防止作弊
4. **断线处理**
- 实现断线重连逻辑
- 使用 `onLeave` 保存玩家状态
5. **房间生命周期**
- 使用 `autoDispose` 自动清理空房间
-`onDispose` 中保存重要数据