Compare commits
2 Commits
@esengine/
...
@esengine/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3e54cb45 | ||
|
|
b6f1235239 |
@@ -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`
|
||||
|
||||
@@ -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` 中保存重要数据
|
||||
|
||||
20
packages/framework/server/CHANGELOG.md
Normal file
20
packages/framework/server/CHANGELOG.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# @esengine/server
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#366](https://github.com/esengine/esengine/pull/366) [`b6f1235`](https://github.com/esengine/esengine/commit/b6f1235239c049abc62b6827554eb941e73dae65) Thanks [@esengine](https://github.com/esengine)! - feat(server): 添加游戏服务器框架与房间系统 | add game server framework with Room system
|
||||
|
||||
**@esengine/server** - 游戏服务器框架 | Game server framework
|
||||
- 文件路由系统 | File-based routing
|
||||
- Room 生命周期管理 (onCreate, onJoin, onLeave, onTick, onDispose) | Room lifecycle management
|
||||
- `@onMessage` 装饰器处理消息 | Message handler decorator
|
||||
- 玩家管理与断线处理 | Player management with auto-disconnect
|
||||
- 内置 JoinRoom/LeaveRoom API | Built-in room APIs
|
||||
- defineApi/defineMsg 类型安全辅助函数 | Type-safe helper functions
|
||||
|
||||
**create-esengine-server** - CLI 脚手架工具 | CLI scaffolding tool
|
||||
- 生成 shared/server/client 项目结构 | Creates project structure
|
||||
- 类型安全的协议定义 | Type-safe protocol definitions
|
||||
- 包含 GameRoom 示例实现 | Includes example implementation
|
||||
21
packages/framework/server/module.json
Normal file
21
packages/framework/server/module.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "server",
|
||||
"name": "@esengine/server",
|
||||
"globalKey": "server",
|
||||
"displayName": "Game Server",
|
||||
"description": "Game server framework with file-based routing | 基于文件路由的游戏服务器框架",
|
||||
"version": "1.0.0",
|
||||
"category": "Network",
|
||||
"icon": "Server",
|
||||
"tags": ["server", "rpc", "websocket", "network"],
|
||||
"isCore": false,
|
||||
"defaultEnabled": false,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": false,
|
||||
"platforms": ["nodejs"],
|
||||
"dependencies": ["rpc"],
|
||||
"exports": {
|
||||
"functions": ["createServer", "defineApi", "defineMsg"]
|
||||
},
|
||||
"outputPath": "dist/index.js"
|
||||
}
|
||||
50
packages/framework/server/package.json
Normal file
50
packages/framework/server/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@esengine/server",
|
||||
"version": "1.1.0",
|
||||
"description": "Game server framework for ESEngine with file-based routing",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"module.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"dev": "tsup --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/rpc": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": ">=8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"rimraf": "^5.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"keywords": [
|
||||
"esengine",
|
||||
"game-server",
|
||||
"rpc",
|
||||
"websocket",
|
||||
"ecs"
|
||||
]
|
||||
}
|
||||
259
packages/framework/server/src/core/server.ts
Normal file
259
packages/framework/server/src/core/server.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* @zh 游戏服务器核心
|
||||
* @en Game server core
|
||||
*/
|
||||
|
||||
import * as path from 'node:path'
|
||||
import { serve, type RpcServer } from '@esengine/rpc/server'
|
||||
import { rpc } from '@esengine/rpc'
|
||||
import type {
|
||||
ServerConfig,
|
||||
ServerConnection,
|
||||
GameServer,
|
||||
ApiContext,
|
||||
MsgContext,
|
||||
LoadedApiHandler,
|
||||
LoadedMsgHandler,
|
||||
} from '../types/index.js'
|
||||
import { loadApiHandlers, loadMsgHandlers } from '../router/loader.js'
|
||||
import { RoomManager, type RoomClass, type Room } from '../room/index.js'
|
||||
|
||||
/**
|
||||
* @zh 默认配置
|
||||
* @en Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<Omit<ServerConfig, 'onStart' | 'onConnect' | 'onDisconnect'>> = {
|
||||
port: 3000,
|
||||
apiDir: 'src/api',
|
||||
msgDir: 'src/msg',
|
||||
tickRate: 20,
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建游戏服务器
|
||||
* @en Create game server
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createServer, Room, onMessage } from '@esengine/server'
|
||||
*
|
||||
* class GameRoom extends Room {
|
||||
* onJoin(player) {
|
||||
* this.broadcast('Joined', { id: player.id })
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* const server = await createServer({ port: 3000 })
|
||||
* server.define('game', GameRoom)
|
||||
* await server.start()
|
||||
* ```
|
||||
*/
|
||||
export async function createServer(config: ServerConfig = {}): Promise<GameServer> {
|
||||
const opts = { ...DEFAULT_CONFIG, ...config }
|
||||
const cwd = process.cwd()
|
||||
|
||||
// 加载文件路由处理器
|
||||
const apiHandlers = await loadApiHandlers(path.resolve(cwd, opts.apiDir))
|
||||
const msgHandlers = await loadMsgHandlers(path.resolve(cwd, opts.msgDir))
|
||||
|
||||
if (apiHandlers.length > 0) {
|
||||
console.log(`[Server] Loaded ${apiHandlers.length} API handlers`)
|
||||
}
|
||||
if (msgHandlers.length > 0) {
|
||||
console.log(`[Server] Loaded ${msgHandlers.length} message handlers`)
|
||||
}
|
||||
|
||||
// 动态构建协议
|
||||
const apiDefs: Record<string, ReturnType<typeof rpc.api>> = {
|
||||
// 内置 API
|
||||
JoinRoom: rpc.api(),
|
||||
LeaveRoom: rpc.api(),
|
||||
}
|
||||
const msgDefs: Record<string, ReturnType<typeof rpc.msg>> = {
|
||||
// 内置消息(房间消息透传)
|
||||
RoomMessage: rpc.msg(),
|
||||
}
|
||||
|
||||
for (const handler of apiHandlers) {
|
||||
apiDefs[handler.name] = rpc.api()
|
||||
}
|
||||
for (const handler of msgHandlers) {
|
||||
msgDefs[handler.name] = rpc.msg()
|
||||
}
|
||||
|
||||
const protocol = rpc.define({
|
||||
api: apiDefs,
|
||||
msg: msgDefs,
|
||||
})
|
||||
|
||||
// 服务器状态
|
||||
let currentTick = 0
|
||||
let tickInterval: ReturnType<typeof setInterval> | null = null
|
||||
let rpcServer: RpcServer<typeof protocol, Record<string, unknown>> | null = null
|
||||
|
||||
// 房间管理器
|
||||
let roomManager: RoomManager | null = null
|
||||
|
||||
// 构建 API 处理器映射
|
||||
const apiMap: Record<string, LoadedApiHandler> = {}
|
||||
for (const handler of apiHandlers) {
|
||||
apiMap[handler.name] = handler
|
||||
}
|
||||
|
||||
// 构建消息处理器映射
|
||||
const msgMap: Record<string, LoadedMsgHandler> = {}
|
||||
for (const handler of msgHandlers) {
|
||||
msgMap[handler.name] = handler
|
||||
}
|
||||
|
||||
// 游戏服务器实例
|
||||
const gameServer: GameServer & {
|
||||
rooms: RoomManager | null
|
||||
} = {
|
||||
get connections() {
|
||||
return (rpcServer?.connections ?? []) as ReadonlyArray<ServerConnection>
|
||||
},
|
||||
|
||||
get tick() {
|
||||
return currentTick
|
||||
},
|
||||
|
||||
get rooms() {
|
||||
return roomManager
|
||||
},
|
||||
|
||||
/**
|
||||
* @zh 注册房间类型
|
||||
* @en Define room type
|
||||
*/
|
||||
define(name: string, roomClass: new () => unknown): void {
|
||||
if (!roomManager) {
|
||||
throw new Error('Server not started. Call define() after createServer().')
|
||||
}
|
||||
roomManager.define(name, roomClass as RoomClass)
|
||||
},
|
||||
|
||||
async start() {
|
||||
// 初始化房间管理器
|
||||
roomManager = new RoomManager((conn, type, data) => {
|
||||
rpcServer?.send(conn, 'RoomMessage' as any, { type, data } as any)
|
||||
})
|
||||
|
||||
// 构建 API handlers
|
||||
const apiHandlersObj: Record<string, (input: unknown, conn: any) => Promise<unknown>> = {}
|
||||
|
||||
// 内置 JoinRoom API
|
||||
apiHandlersObj['JoinRoom'] = async (input: any, conn) => {
|
||||
const { roomType, roomId, options } = input as {
|
||||
roomType?: string
|
||||
roomId?: string
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
|
||||
if (roomId) {
|
||||
const result = await roomManager!.joinById(roomId, conn.id, conn)
|
||||
if (!result) {
|
||||
throw new Error('Failed to join room')
|
||||
}
|
||||
return { roomId: result.room.id, playerId: result.player.id }
|
||||
}
|
||||
|
||||
if (roomType) {
|
||||
const result = await roomManager!.joinOrCreate(roomType, conn.id, conn, options)
|
||||
if (!result) {
|
||||
throw new Error('Failed to join or create room')
|
||||
}
|
||||
return { roomId: result.room.id, playerId: result.player.id }
|
||||
}
|
||||
|
||||
throw new Error('roomType or roomId required')
|
||||
}
|
||||
|
||||
// 内置 LeaveRoom API
|
||||
apiHandlersObj['LeaveRoom'] = async (_input, conn) => {
|
||||
await roomManager!.leave(conn.id)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// 文件路由 API
|
||||
for (const [name, handler] of Object.entries(apiMap)) {
|
||||
apiHandlersObj[name] = async (input, conn) => {
|
||||
const ctx: ApiContext = {
|
||||
conn: conn as ServerConnection,
|
||||
server: gameServer,
|
||||
}
|
||||
return handler.definition.handler(input, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建消息 handlers
|
||||
const msgHandlersObj: Record<string, (data: unknown, conn: any) => void | Promise<void>> = {}
|
||||
|
||||
// 内置 RoomMessage 处理
|
||||
msgHandlersObj['RoomMessage'] = async (data: any, conn) => {
|
||||
const { type, payload } = data as { type: string; payload: unknown }
|
||||
roomManager!.handleMessage(conn.id, type, payload)
|
||||
}
|
||||
|
||||
// 文件路由消息
|
||||
for (const [name, handler] of Object.entries(msgMap)) {
|
||||
msgHandlersObj[name] = async (data, conn) => {
|
||||
const ctx: MsgContext = {
|
||||
conn: conn as ServerConnection,
|
||||
server: gameServer,
|
||||
}
|
||||
await handler.definition.handler(data, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
rpcServer = serve(protocol, {
|
||||
port: opts.port,
|
||||
createConnData: () => ({}),
|
||||
onStart: (p) => {
|
||||
console.log(`[Server] Started on ws://localhost:${p}`)
|
||||
opts.onStart?.(p)
|
||||
},
|
||||
onConnect: async (conn) => {
|
||||
await config.onConnect?.(conn as ServerConnection)
|
||||
},
|
||||
onDisconnect: async (conn) => {
|
||||
// 玩家断线时自动离开房间
|
||||
await roomManager?.leave(conn.id, 'disconnected')
|
||||
await config.onDisconnect?.(conn as ServerConnection)
|
||||
},
|
||||
api: apiHandlersObj as any,
|
||||
msg: msgHandlersObj as any,
|
||||
})
|
||||
|
||||
await rpcServer.start()
|
||||
|
||||
// 启动 tick 循环
|
||||
if (opts.tickRate > 0) {
|
||||
tickInterval = setInterval(() => {
|
||||
currentTick++
|
||||
}, 1000 / opts.tickRate)
|
||||
}
|
||||
},
|
||||
|
||||
async stop() {
|
||||
if (tickInterval) {
|
||||
clearInterval(tickInterval)
|
||||
tickInterval = null
|
||||
}
|
||||
if (rpcServer) {
|
||||
await rpcServer.stop()
|
||||
rpcServer = null
|
||||
}
|
||||
},
|
||||
|
||||
broadcast(name, data) {
|
||||
rpcServer?.broadcast(name as any, data as any)
|
||||
},
|
||||
|
||||
send(conn, name, data) {
|
||||
rpcServer?.send(conn as any, name as any, data as any)
|
||||
},
|
||||
}
|
||||
|
||||
return gameServer as GameServer
|
||||
}
|
||||
51
packages/framework/server/src/helpers/define.ts
Normal file
51
packages/framework/server/src/helpers/define.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @zh API 和消息定义助手
|
||||
* @en API and message definition helpers
|
||||
*/
|
||||
|
||||
import type { ApiDefinition, MsgDefinition } from '../types/index.js'
|
||||
|
||||
/**
|
||||
* @zh 定义 API 处理器
|
||||
* @en Define API handler
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // src/api/join.ts
|
||||
* import { defineApi } from '@esengine/server'
|
||||
*
|
||||
* export default defineApi<ReqJoin, ResJoin>({
|
||||
* handler(req, ctx) {
|
||||
* ctx.conn.data.playerId = generateId()
|
||||
* return { playerId: ctx.conn.data.playerId }
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function defineApi<TReq, TRes, TData = Record<string, unknown>>(
|
||||
definition: ApiDefinition<TReq, TRes, TData>
|
||||
): ApiDefinition<TReq, TRes, TData> {
|
||||
return definition
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 定义消息处理器
|
||||
* @en Define message handler
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // src/msg/input.ts
|
||||
* import { defineMsg } from '@esengine/server'
|
||||
*
|
||||
* export default defineMsg<MsgInput>({
|
||||
* handler(msg, ctx) {
|
||||
* console.log('Input from', ctx.conn.id, msg)
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function defineMsg<TMsg, TData = Record<string, unknown>>(
|
||||
definition: MsgDefinition<TMsg, TData>
|
||||
): MsgDefinition<TMsg, TData> {
|
||||
return definition
|
||||
}
|
||||
52
packages/framework/server/src/index.ts
Normal file
52
packages/framework/server/src/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @zh ESEngine 游戏服务器框架
|
||||
* @en ESEngine Game Server Framework
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createServer, Room, onMessage } from '@esengine/server'
|
||||
*
|
||||
* class GameRoom extends Room {
|
||||
* maxPlayers = 4
|
||||
* tickRate = 20
|
||||
*
|
||||
* onJoin(player) {
|
||||
* this.broadcast('Joined', { id: player.id })
|
||||
* }
|
||||
*
|
||||
* @onMessage('Move')
|
||||
* handleMove(data, player) {
|
||||
* // handle move
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* const server = await createServer({ port: 3000 })
|
||||
* server.define('game', GameRoom)
|
||||
* await server.start()
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Core
|
||||
export { createServer } from './core/server.js'
|
||||
|
||||
// Helpers
|
||||
export { defineApi, defineMsg } from './helpers/define.js'
|
||||
|
||||
// Room System
|
||||
export { Room, type RoomOptions } from './room/Room.js'
|
||||
export { Player, type IPlayer } from './room/Player.js'
|
||||
export { onMessage } from './room/decorators.js'
|
||||
|
||||
// Types
|
||||
export type {
|
||||
ServerConfig,
|
||||
ServerConnection,
|
||||
GameServer,
|
||||
ApiContext,
|
||||
MsgContext,
|
||||
ApiDefinition,
|
||||
MsgDefinition,
|
||||
} from './types/index.js'
|
||||
|
||||
// Re-export useful types from @esengine/rpc
|
||||
export { RpcError, ErrorCode } from '@esengine/rpc'
|
||||
72
packages/framework/server/src/room/Player.ts
Normal file
72
packages/framework/server/src/room/Player.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @zh 玩家类
|
||||
* @en Player class
|
||||
*/
|
||||
|
||||
import type { Connection } from '@esengine/rpc'
|
||||
|
||||
/**
|
||||
* @zh 玩家接口
|
||||
* @en Player interface
|
||||
*/
|
||||
export interface IPlayer<TData = Record<string, unknown>> {
|
||||
readonly id: string
|
||||
readonly roomId: string
|
||||
data: TData
|
||||
send<T>(type: string, data: T): void
|
||||
leave(reason?: string): void
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 玩家实现
|
||||
* @en Player implementation
|
||||
*/
|
||||
export class Player<TData = Record<string, unknown>> implements IPlayer<TData> {
|
||||
readonly id: string
|
||||
readonly roomId: string
|
||||
data: TData
|
||||
|
||||
private _conn: Connection<any>
|
||||
private _sendFn: (conn: Connection<any>, type: string, data: unknown) => void
|
||||
private _leaveFn: (player: Player<TData>, reason?: string) => void
|
||||
|
||||
constructor(options: {
|
||||
id: string
|
||||
roomId: string
|
||||
conn: Connection<any>
|
||||
sendFn: (conn: Connection<any>, type: string, data: unknown) => void
|
||||
leaveFn: (player: Player<TData>, reason?: string) => void
|
||||
initialData?: TData
|
||||
}) {
|
||||
this.id = options.id
|
||||
this.roomId = options.roomId
|
||||
this._conn = options.conn
|
||||
this._sendFn = options.sendFn
|
||||
this._leaveFn = options.leaveFn
|
||||
this.data = options.initialData ?? ({} as TData)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取底层连接
|
||||
* @en Get underlying connection
|
||||
*/
|
||||
get connection(): Connection<any> {
|
||||
return this._conn
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 发送消息给玩家
|
||||
* @en Send message to player
|
||||
*/
|
||||
send<T>(type: string, data: T): void {
|
||||
this._sendFn(this._conn, type, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 让玩家离开房间
|
||||
* @en Make player leave the room
|
||||
*/
|
||||
leave(reason?: string): void {
|
||||
this._leaveFn(this, reason)
|
||||
}
|
||||
}
|
||||
383
packages/framework/server/src/room/Room.ts
Normal file
383
packages/framework/server/src/room/Room.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @zh 房间基类
|
||||
* @en Room base class
|
||||
*/
|
||||
|
||||
import { Player } from './Player.js'
|
||||
|
||||
/**
|
||||
* @zh 房间配置
|
||||
* @en Room options
|
||||
*/
|
||||
export interface RoomOptions {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 消息处理器元数据
|
||||
* @en Message handler metadata
|
||||
*/
|
||||
interface MessageHandlerMeta {
|
||||
type: string
|
||||
method: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 消息处理器存储 key
|
||||
* @en Message handler storage key
|
||||
*/
|
||||
const MESSAGE_HANDLERS = Symbol('messageHandlers')
|
||||
|
||||
/**
|
||||
* @zh 房间基类
|
||||
* @en Room base class
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class GameRoom extends Room {
|
||||
* maxPlayers = 4
|
||||
* tickRate = 20
|
||||
*
|
||||
* onJoin(player: Player) {
|
||||
* this.broadcast('Joined', { id: player.id })
|
||||
* }
|
||||
*
|
||||
* @onMessage('Move')
|
||||
* handleMove(data: { x: number, y: number }, player: Player) {
|
||||
* // handle move
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export abstract class Room<TState = any, TPlayerData = Record<string, unknown>> {
|
||||
// ========================================================================
|
||||
// 配置 | Configuration
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @zh 最大玩家数
|
||||
* @en Maximum players
|
||||
*/
|
||||
maxPlayers = 16
|
||||
|
||||
/**
|
||||
* @zh Tick 速率(每秒),0 = 不自动 tick
|
||||
* @en Tick rate (per second), 0 = no auto tick
|
||||
*/
|
||||
tickRate = 0
|
||||
|
||||
/**
|
||||
* @zh 空房间自动销毁
|
||||
* @en Auto dispose when empty
|
||||
*/
|
||||
autoDispose = true
|
||||
|
||||
// ========================================================================
|
||||
// 状态 | State
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @zh 房间状态
|
||||
* @en Room state
|
||||
*/
|
||||
state: TState = {} as TState
|
||||
|
||||
// ========================================================================
|
||||
// 内部属性 | Internal properties
|
||||
// ========================================================================
|
||||
|
||||
private _id: string = ''
|
||||
private _players: Map<string, Player<TPlayerData>> = new Map()
|
||||
private _locked = false
|
||||
private _disposed = false
|
||||
private _tickInterval: ReturnType<typeof setInterval> | null = null
|
||||
private _lastTickTime = 0
|
||||
private _broadcastFn: ((type: string, data: unknown) => void) | null = null
|
||||
private _sendFn: ((conn: any, type: string, data: unknown) => void) | null = null
|
||||
private _disposeFn: (() => void) | null = null
|
||||
|
||||
// ========================================================================
|
||||
// 只读属性 | Readonly properties
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @zh 房间 ID
|
||||
* @en Room ID
|
||||
*/
|
||||
get id(): string {
|
||||
return this._id
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 所有玩家
|
||||
* @en All players
|
||||
*/
|
||||
get players(): ReadonlyArray<Player<TPlayerData>> {
|
||||
return Array.from(this._players.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 玩家数量
|
||||
* @en Player count
|
||||
*/
|
||||
get playerCount(): number {
|
||||
return this._players.size
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否已满
|
||||
* @en Is full
|
||||
*/
|
||||
get isFull(): boolean {
|
||||
return this._players.size >= this.maxPlayers
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否已锁定
|
||||
* @en Is locked
|
||||
*/
|
||||
get isLocked(): boolean {
|
||||
return this._locked
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否已销毁
|
||||
* @en Is disposed
|
||||
*/
|
||||
get isDisposed(): boolean {
|
||||
return this._disposed
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 生命周期 | Lifecycle
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @zh 房间创建时调用
|
||||
* @en Called when room is created
|
||||
*/
|
||||
onCreate(options?: RoomOptions): void | Promise<void> {}
|
||||
|
||||
/**
|
||||
* @zh 玩家加入时调用
|
||||
* @en Called when player joins
|
||||
*/
|
||||
onJoin(player: Player<TPlayerData>): void | Promise<void> {}
|
||||
|
||||
/**
|
||||
* @zh 玩家离开时调用
|
||||
* @en Called when player leaves
|
||||
*/
|
||||
onLeave(player: Player<TPlayerData>, reason?: string): void | Promise<void> {}
|
||||
|
||||
/**
|
||||
* @zh 游戏循环
|
||||
* @en Game tick
|
||||
*/
|
||||
onTick(dt: number): void {}
|
||||
|
||||
/**
|
||||
* @zh 房间销毁时调用
|
||||
* @en Called when room is disposed
|
||||
*/
|
||||
onDispose(): void | Promise<void> {}
|
||||
|
||||
// ========================================================================
|
||||
// 公共方法 | Public methods
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @zh 广播消息给所有玩家
|
||||
* @en Broadcast message to all players
|
||||
*/
|
||||
broadcast<T>(type: string, data: T): void {
|
||||
for (const player of this._players.values()) {
|
||||
player.send(type, data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 广播消息给除指定玩家外的所有玩家
|
||||
* @en Broadcast message to all players except one
|
||||
*/
|
||||
broadcastExcept<T>(except: Player<TPlayerData>, type: string, data: T): void {
|
||||
for (const player of this._players.values()) {
|
||||
if (player.id !== except.id) {
|
||||
player.send(type, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取玩家
|
||||
* @en Get player by id
|
||||
*/
|
||||
getPlayer(id: string): Player<TPlayerData> | undefined {
|
||||
return this._players.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 踢出玩家
|
||||
* @en Kick player
|
||||
*/
|
||||
kick(player: Player<TPlayerData>, reason?: string): void {
|
||||
player.leave(reason ?? 'kicked')
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 锁定房间
|
||||
* @en Lock room
|
||||
*/
|
||||
lock(): void {
|
||||
this._locked = true
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解锁房间
|
||||
* @en Unlock room
|
||||
*/
|
||||
unlock(): void {
|
||||
this._locked = false
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 手动销毁房间
|
||||
* @en Manually dispose room
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this._disposed) return
|
||||
this._disposed = true
|
||||
|
||||
this._stopTick()
|
||||
|
||||
for (const player of this._players.values()) {
|
||||
player.leave('room_disposed')
|
||||
}
|
||||
this._players.clear()
|
||||
|
||||
this.onDispose()
|
||||
this._disposeFn?.()
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 内部方法 | Internal methods
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
_init(options: {
|
||||
id: string
|
||||
sendFn: (conn: any, type: string, data: unknown) => void
|
||||
broadcastFn: (type: string, data: unknown) => void
|
||||
disposeFn: () => void
|
||||
}): void {
|
||||
this._id = options.id
|
||||
this._sendFn = options.sendFn
|
||||
this._broadcastFn = options.broadcastFn
|
||||
this._disposeFn = options.disposeFn
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
async _create(options?: RoomOptions): Promise<void> {
|
||||
await this.onCreate(options)
|
||||
this._startTick()
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
async _addPlayer(id: string, conn: any): Promise<Player<TPlayerData> | null> {
|
||||
if (this._locked || this.isFull || this._disposed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const player = new Player<TPlayerData>({
|
||||
id,
|
||||
roomId: this._id,
|
||||
conn,
|
||||
sendFn: this._sendFn!,
|
||||
leaveFn: (p, reason) => this._removePlayer(p.id, reason),
|
||||
})
|
||||
|
||||
this._players.set(id, player)
|
||||
await this.onJoin(player)
|
||||
|
||||
return player
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
async _removePlayer(id: string, reason?: string): Promise<void> {
|
||||
const player = this._players.get(id)
|
||||
if (!player) return
|
||||
|
||||
this._players.delete(id)
|
||||
await this.onLeave(player, reason)
|
||||
|
||||
if (this.autoDispose && this._players.size === 0) {
|
||||
this.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
_handleMessage(type: string, data: unknown, playerId: string): void {
|
||||
const player = this._players.get(playerId)
|
||||
if (!player) return
|
||||
|
||||
const handlers = (this.constructor as any)[MESSAGE_HANDLERS] as MessageHandlerMeta[] | undefined
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
if (handler.type === type) {
|
||||
const method = (this as any)[handler.method]
|
||||
if (typeof method === 'function') {
|
||||
method.call(this, data, player)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _startTick(): void {
|
||||
if (this.tickRate <= 0) return
|
||||
|
||||
this._lastTickTime = performance.now()
|
||||
this._tickInterval = setInterval(() => {
|
||||
const now = performance.now()
|
||||
const dt = (now - this._lastTickTime) / 1000
|
||||
this._lastTickTime = now
|
||||
this.onTick(dt)
|
||||
}, 1000 / this.tickRate)
|
||||
}
|
||||
|
||||
private _stopTick(): void {
|
||||
if (this._tickInterval) {
|
||||
clearInterval(this._tickInterval)
|
||||
this._tickInterval = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取消息处理器元数据
|
||||
* @en Get message handler metadata
|
||||
*/
|
||||
export function getMessageHandlers(target: any): MessageHandlerMeta[] {
|
||||
return target[MESSAGE_HANDLERS] || []
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 注册消息处理器元数据
|
||||
* @en Register message handler metadata
|
||||
*/
|
||||
export function registerMessageHandler(target: any, type: string, method: string): void {
|
||||
if (!target[MESSAGE_HANDLERS]) {
|
||||
target[MESSAGE_HANDLERS] = []
|
||||
}
|
||||
target[MESSAGE_HANDLERS].push({ type, method })
|
||||
}
|
||||
221
packages/framework/server/src/room/RoomManager.ts
Normal file
221
packages/framework/server/src/room/RoomManager.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @zh 房间管理器
|
||||
* @en Room manager
|
||||
*/
|
||||
|
||||
import { Room, type RoomOptions } from './Room.js'
|
||||
import type { Player } from './Player.js'
|
||||
|
||||
/**
|
||||
* @zh 房间类型
|
||||
* @en Room class type
|
||||
*/
|
||||
export type RoomClass<T extends Room = Room> = new () => T
|
||||
|
||||
/**
|
||||
* @zh 房间定义
|
||||
* @en Room definition
|
||||
*/
|
||||
interface RoomDefinition {
|
||||
roomClass: RoomClass
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 房间管理器
|
||||
* @en Room manager
|
||||
*/
|
||||
export class RoomManager {
|
||||
private _definitions: Map<string, RoomDefinition> = new Map()
|
||||
private _rooms: Map<string, Room> = new Map()
|
||||
private _playerToRoom: Map<string, string> = new Map()
|
||||
private _nextRoomId = 1
|
||||
|
||||
private _sendFn: (conn: any, type: string, data: unknown) => void
|
||||
|
||||
constructor(sendFn: (conn: any, type: string, data: unknown) => void) {
|
||||
this._sendFn = sendFn
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 注册房间类型
|
||||
* @en Define room type
|
||||
*/
|
||||
define<T extends Room>(name: string, roomClass: RoomClass<T>): void {
|
||||
this._definitions.set(name, { roomClass })
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建房间
|
||||
* @en Create room
|
||||
*/
|
||||
async create(name: string, options?: RoomOptions): Promise<Room | null> {
|
||||
const def = this._definitions.get(name)
|
||||
if (!def) {
|
||||
console.warn(`[RoomManager] Room type not found: ${name}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const roomId = this._generateRoomId()
|
||||
const room = new def.roomClass()
|
||||
|
||||
room._init({
|
||||
id: roomId,
|
||||
sendFn: this._sendFn,
|
||||
broadcastFn: (type, data) => {
|
||||
for (const player of room.players) {
|
||||
player.send(type, data)
|
||||
}
|
||||
},
|
||||
disposeFn: () => {
|
||||
this._rooms.delete(roomId)
|
||||
},
|
||||
})
|
||||
|
||||
this._rooms.set(roomId, room)
|
||||
await room._create(options)
|
||||
|
||||
console.log(`[Room] Created: ${name} (${roomId})`)
|
||||
return room
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 加入或创建房间
|
||||
* @en Join or create room
|
||||
*/
|
||||
async joinOrCreate(
|
||||
name: string,
|
||||
playerId: string,
|
||||
conn: any,
|
||||
options?: RoomOptions
|
||||
): Promise<{ room: Room; player: Player } | null> {
|
||||
// 查找可加入的房间
|
||||
let room = this._findAvailableRoom(name)
|
||||
|
||||
// 没有则创建
|
||||
if (!room) {
|
||||
room = await this.create(name, options)
|
||||
if (!room) return null
|
||||
}
|
||||
|
||||
// 加入房间
|
||||
const player = await room._addPlayer(playerId, conn)
|
||||
if (!player) return null
|
||||
|
||||
this._playerToRoom.set(playerId, room.id)
|
||||
|
||||
console.log(`[Room] Player ${playerId} joined ${room.id}`)
|
||||
return { room, player }
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 加入指定房间
|
||||
* @en Join specific room
|
||||
*/
|
||||
async joinById(
|
||||
roomId: string,
|
||||
playerId: string,
|
||||
conn: any
|
||||
): Promise<{ room: Room; player: Player } | null> {
|
||||
const room = this._rooms.get(roomId)
|
||||
if (!room) return null
|
||||
|
||||
const player = await room._addPlayer(playerId, conn)
|
||||
if (!player) return null
|
||||
|
||||
this._playerToRoom.set(playerId, room.id)
|
||||
|
||||
console.log(`[Room] Player ${playerId} joined ${room.id}`)
|
||||
return { room, player }
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 玩家离开
|
||||
* @en Player leave
|
||||
*/
|
||||
async leave(playerId: string, reason?: string): Promise<void> {
|
||||
const roomId = this._playerToRoom.get(playerId)
|
||||
if (!roomId) return
|
||||
|
||||
const room = this._rooms.get(roomId)
|
||||
if (room) {
|
||||
await room._removePlayer(playerId, reason)
|
||||
}
|
||||
|
||||
this._playerToRoom.delete(playerId)
|
||||
console.log(`[Room] Player ${playerId} left ${roomId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 处理消息
|
||||
* @en Handle message
|
||||
*/
|
||||
handleMessage(playerId: string, type: string, data: unknown): void {
|
||||
const roomId = this._playerToRoom.get(playerId)
|
||||
if (!roomId) return
|
||||
|
||||
const room = this._rooms.get(roomId)
|
||||
if (room) {
|
||||
room._handleMessage(type, data, playerId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取房间
|
||||
* @en Get room
|
||||
*/
|
||||
getRoom(roomId: string): Room | undefined {
|
||||
return this._rooms.get(roomId)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取玩家所在房间
|
||||
* @en Get player's room
|
||||
*/
|
||||
getPlayerRoom(playerId: string): Room | undefined {
|
||||
const roomId = this._playerToRoom.get(playerId)
|
||||
return roomId ? this._rooms.get(roomId) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取所有房间
|
||||
* @en Get all rooms
|
||||
*/
|
||||
getRooms(): ReadonlyArray<Room> {
|
||||
return Array.from(this._rooms.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取指定类型的所有房间
|
||||
* @en Get all rooms of a type
|
||||
*/
|
||||
getRoomsByType(name: string): Room[] {
|
||||
const def = this._definitions.get(name)
|
||||
if (!def) return []
|
||||
|
||||
return Array.from(this._rooms.values()).filter(
|
||||
room => room instanceof def.roomClass
|
||||
)
|
||||
}
|
||||
|
||||
private _findAvailableRoom(name: string): Room | undefined {
|
||||
const def = this._definitions.get(name)
|
||||
if (!def) return undefined
|
||||
|
||||
for (const room of this._rooms.values()) {
|
||||
if (
|
||||
room instanceof def.roomClass &&
|
||||
!room.isFull &&
|
||||
!room.isLocked &&
|
||||
!room.isDisposed
|
||||
) {
|
||||
return room
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private _generateRoomId(): string {
|
||||
return `room_${this._nextRoomId++}`
|
||||
}
|
||||
}
|
||||
35
packages/framework/server/src/room/decorators.ts
Normal file
35
packages/framework/server/src/room/decorators.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @zh 房间装饰器
|
||||
* @en Room decorators
|
||||
*/
|
||||
|
||||
import { registerMessageHandler } from './Room.js'
|
||||
|
||||
/**
|
||||
* @zh 消息处理器装饰器
|
||||
* @en Message handler decorator
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class GameRoom extends Room {
|
||||
* @onMessage('Move')
|
||||
* handleMove(data: { x: number, y: number }, player: Player) {
|
||||
* // handle move
|
||||
* }
|
||||
*
|
||||
* @onMessage('Chat')
|
||||
* handleChat(data: { text: string }, player: Player) {
|
||||
* this.broadcast('Chat', { from: player.id, text: data.text })
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function onMessage(type: string): MethodDecorator {
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
_descriptor: PropertyDescriptor
|
||||
) {
|
||||
registerMessageHandler(target.constructor, type, propertyKey as string)
|
||||
}
|
||||
}
|
||||
9
packages/framework/server/src/room/index.ts
Normal file
9
packages/framework/server/src/room/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @zh 房间系统
|
||||
* @en Room system
|
||||
*/
|
||||
|
||||
export { Room, type RoomOptions } from './Room.js'
|
||||
export { Player, type IPlayer } from './Player.js'
|
||||
export { RoomManager, type RoomClass } from './RoomManager.js'
|
||||
export { onMessage } from './decorators.js'
|
||||
112
packages/framework/server/src/router/loader.ts
Normal file
112
packages/framework/server/src/router/loader.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @zh 文件路由加载器
|
||||
* @en File-based router loader
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type { ApiDefinition, MsgDefinition, LoadedApiHandler, LoadedMsgHandler } from '../types/index.js'
|
||||
|
||||
/**
|
||||
* @zh 将文件名转换为 API/消息名称
|
||||
* @en Convert filename to API/message name
|
||||
*
|
||||
* @example
|
||||
* 'join.ts' -> 'Join'
|
||||
* 'spawn-agent.ts' -> 'SpawnAgent'
|
||||
* 'save_blueprint.ts' -> 'SaveBlueprint'
|
||||
*/
|
||||
function fileNameToHandlerName(fileName: string): string {
|
||||
const baseName = fileName.replace(/\.(ts|js|mts|mjs)$/, '')
|
||||
|
||||
return baseName
|
||||
.split(/[-_]/)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 扫描目录获取所有处理器文件
|
||||
* @en Scan directory for all handler files
|
||||
*/
|
||||
function scanDirectory(dir: string): string[] {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const files: string[] = []
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && /\.(ts|js|mts|mjs)$/.test(entry.name)) {
|
||||
// 跳过 index 和下划线开头的文件
|
||||
if (entry.name.startsWith('_') || entry.name.startsWith('index.')) {
|
||||
continue
|
||||
}
|
||||
files.push(path.join(dir, entry.name))
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 加载 API 处理器
|
||||
* @en Load API handlers
|
||||
*/
|
||||
export async function loadApiHandlers(apiDir: string): Promise<LoadedApiHandler[]> {
|
||||
const files = scanDirectory(apiDir)
|
||||
const handlers: LoadedApiHandler[] = []
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const fileUrl = pathToFileURL(filePath).href
|
||||
const module = await import(fileUrl)
|
||||
const definition = module.default as ApiDefinition<unknown, unknown, unknown>
|
||||
|
||||
if (definition && typeof definition.handler === 'function') {
|
||||
const name = fileNameToHandlerName(path.basename(filePath))
|
||||
handlers.push({
|
||||
name,
|
||||
path: filePath,
|
||||
definition,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[Server] Failed to load API handler: ${filePath}`, err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlers
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 加载消息处理器
|
||||
* @en Load message handlers
|
||||
*/
|
||||
export async function loadMsgHandlers(msgDir: string): Promise<LoadedMsgHandler[]> {
|
||||
const files = scanDirectory(msgDir)
|
||||
const handlers: LoadedMsgHandler[] = []
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const fileUrl = pathToFileURL(filePath).href
|
||||
const module = await import(fileUrl)
|
||||
const definition = module.default as MsgDefinition<unknown, unknown>
|
||||
|
||||
if (definition && typeof definition.handler === 'function') {
|
||||
const name = fileNameToHandlerName(path.basename(filePath))
|
||||
handlers.push({
|
||||
name,
|
||||
path: filePath,
|
||||
definition,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[Server] Failed to load msg handler: ${filePath}`, err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlers
|
||||
}
|
||||
228
packages/framework/server/src/types/index.ts
Normal file
228
packages/framework/server/src/types/index.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @zh ESEngine Server 类型定义
|
||||
* @en ESEngine Server type definitions
|
||||
*/
|
||||
|
||||
import type { Connection, ProtocolDef } from '@esengine/rpc'
|
||||
|
||||
// ============================================================================
|
||||
// Server Config
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 服务器配置
|
||||
* @en Server configuration
|
||||
*/
|
||||
export interface ServerConfig {
|
||||
/**
|
||||
* @zh 监听端口
|
||||
* @en Listen port
|
||||
* @default 3000
|
||||
*/
|
||||
port?: number
|
||||
|
||||
/**
|
||||
* @zh API 目录路径
|
||||
* @en API directory path
|
||||
* @default 'src/api'
|
||||
*/
|
||||
apiDir?: string
|
||||
|
||||
/**
|
||||
* @zh 消息处理器目录路径
|
||||
* @en Message handlers directory path
|
||||
* @default 'src/msg'
|
||||
*/
|
||||
msgDir?: string
|
||||
|
||||
/**
|
||||
* @zh 游戏 Tick 速率 (每秒)
|
||||
* @en Game tick rate (per second)
|
||||
* @default 20
|
||||
*/
|
||||
tickRate?: number
|
||||
|
||||
/**
|
||||
* @zh 服务器启动回调
|
||||
* @en Server start callback
|
||||
*/
|
||||
onStart?: (port: number) => void
|
||||
|
||||
/**
|
||||
* @zh 连接建立回调
|
||||
* @en Connection established callback
|
||||
*/
|
||||
onConnect?: (conn: ServerConnection) => void | Promise<void>
|
||||
|
||||
/**
|
||||
* @zh 连接断开回调
|
||||
* @en Connection closed callback
|
||||
*/
|
||||
onDisconnect?: (conn: ServerConnection) => void | Promise<void>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 服务器连接(扩展 RPC Connection)
|
||||
* @en Server connection (extends RPC Connection)
|
||||
*/
|
||||
export interface ServerConnection<TData = Record<string, unknown>> extends Connection<TData> {
|
||||
/**
|
||||
* @zh 用户自定义数据
|
||||
* @en User-defined data
|
||||
*/
|
||||
data: TData
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Definition
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh API 处理器上下文
|
||||
* @en API handler context
|
||||
*/
|
||||
export interface ApiContext<TData = Record<string, unknown>> {
|
||||
/**
|
||||
* @zh 当前连接
|
||||
* @en Current connection
|
||||
*/
|
||||
conn: ServerConnection<TData>
|
||||
|
||||
/**
|
||||
* @zh 服务器实例
|
||||
* @en Server instance
|
||||
*/
|
||||
server: GameServer
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh API 定义选项
|
||||
* @en API definition options
|
||||
*/
|
||||
export interface ApiDefinition<TReq = unknown, TRes = unknown, TData = Record<string, unknown>> {
|
||||
/**
|
||||
* @zh API 处理函数
|
||||
* @en API handler function
|
||||
*/
|
||||
handler: (req: TReq, ctx: ApiContext<TData>) => TRes | Promise<TRes>
|
||||
|
||||
/**
|
||||
* @zh 请求验证函数(可选)
|
||||
* @en Request validation function (optional)
|
||||
*/
|
||||
validate?: (req: unknown) => req is TReq
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Definition
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 消息处理器上下文
|
||||
* @en Message handler context
|
||||
*/
|
||||
export interface MsgContext<TData = Record<string, unknown>> {
|
||||
/**
|
||||
* @zh 当前连接
|
||||
* @en Current connection
|
||||
*/
|
||||
conn: ServerConnection<TData>
|
||||
|
||||
/**
|
||||
* @zh 服务器实例
|
||||
* @en Server instance
|
||||
*/
|
||||
server: GameServer
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 消息定义选项
|
||||
* @en Message definition options
|
||||
*/
|
||||
export interface MsgDefinition<TMsg = unknown, TData = Record<string, unknown>> {
|
||||
/**
|
||||
* @zh 消息处理函数
|
||||
* @en Message handler function
|
||||
*/
|
||||
handler: (msg: TMsg, ctx: MsgContext<TData>) => void | Promise<void>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Game Server Interface
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 游戏服务器接口
|
||||
* @en Game server interface
|
||||
*/
|
||||
export interface GameServer {
|
||||
/**
|
||||
* @zh 启动服务器
|
||||
* @en Start server
|
||||
*/
|
||||
start(): Promise<void>
|
||||
|
||||
/**
|
||||
* @zh 停止服务器
|
||||
* @en Stop server
|
||||
*/
|
||||
stop(): Promise<void>
|
||||
|
||||
/**
|
||||
* @zh 广播消息
|
||||
* @en Broadcast message
|
||||
*/
|
||||
broadcast<T>(name: string, data: T): void
|
||||
|
||||
/**
|
||||
* @zh 发送消息给指定连接
|
||||
* @en Send message to specific connection
|
||||
*/
|
||||
send<T>(conn: ServerConnection, name: string, data: T): void
|
||||
|
||||
/**
|
||||
* @zh 获取所有连接
|
||||
* @en Get all connections
|
||||
*/
|
||||
readonly connections: ReadonlyArray<ServerConnection>
|
||||
|
||||
/**
|
||||
* @zh 当前 Tick
|
||||
* @en Current tick
|
||||
*/
|
||||
readonly tick: number
|
||||
|
||||
/**
|
||||
* @zh 注册房间类型
|
||||
* @en Define room type
|
||||
*/
|
||||
define(name: string, roomClass: new () => unknown): void
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Loaded Handler Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 已加载的 API 处理器
|
||||
* @en Loaded API handler
|
||||
*/
|
||||
export interface LoadedApiHandler {
|
||||
name: string
|
||||
path: string
|
||||
definition: ApiDefinition<any, any, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 已加载的消息处理器
|
||||
* @en Loaded message handler
|
||||
*/
|
||||
export interface LoadedMsgHandler {
|
||||
name: string
|
||||
path: string
|
||||
definition: MsgDefinition<any, any>
|
||||
}
|
||||
9
packages/framework/server/tsconfig.json
Normal file
9
packages/framework/server/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
11
packages/framework/server/tsup.config.ts
Normal file
11
packages/framework/server/tsup.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
external: ['ws', '@esengine/rpc'],
|
||||
treeshake: true,
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
# @esengine/network-server
|
||||
|
||||
## 1.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#354](https://github.com/esengine/esengine/pull/354) [`1e240e8`](https://github.com/esengine/esengine/commit/1e240e86f2f75672c3609c9d86238a9ec08ebb4e) Thanks [@esengine](https://github.com/esengine)! - feat(cli): 增强 Node.js 服务端适配器
|
||||
|
||||
**@esengine/cli:**
|
||||
- 添加 @esengine/network-server 依赖支持
|
||||
- 生成完整的 ECS 游戏服务器项目结构
|
||||
- 组件使用 @ECSComponent 装饰器注册
|
||||
- tsconfig 启用 experimentalDecorators
|
||||
|
||||
**@esengine/network-server:**
|
||||
- 支持 ESM/CJS 双格式导出
|
||||
- 添加 ws@8.18.0 解决 Node.js 24 兼容性问题
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"name": "@esengine/network-server",
|
||||
"version": "1.0.2",
|
||||
"description": "TSRPC-based network server for ESEngine",
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsx src/main.ts",
|
||||
"start": "node dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/network-protocols": "workspace:*",
|
||||
"tsrpc": "^3.4.15",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.19.0"
|
||||
},
|
||||
"keywords": [
|
||||
"esengine",
|
||||
"network",
|
||||
"server",
|
||||
"tsrpc",
|
||||
"websocket"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* @esengine/network-server
|
||||
*
|
||||
* 基于 TSRPC 的网络服务器模块
|
||||
* TSRPC-based network server module
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Re-export from protocols | 从协议包重新导出
|
||||
// ============================================================================
|
||||
|
||||
export type {
|
||||
ServiceType,
|
||||
IEntityState,
|
||||
IPlayerInput,
|
||||
MsgSync,
|
||||
MsgInput,
|
||||
MsgSpawn,
|
||||
MsgDespawn,
|
||||
ReqJoin,
|
||||
ResJoin
|
||||
} from '@esengine/network-protocols';
|
||||
|
||||
export { serviceProto } from '@esengine/network-protocols';
|
||||
|
||||
// ============================================================================
|
||||
// Server | 服务器
|
||||
// ============================================================================
|
||||
|
||||
export { GameServer, type IServerConfig } from './services/GameServer';
|
||||
export { Room, type IPlayer, type IRoomConfig } from './services/Room';
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* 服务器入口
|
||||
* Server entry point
|
||||
*/
|
||||
import { GameServer } from './services/GameServer';
|
||||
|
||||
const PORT = parseInt(process.env['PORT'] ?? '3000', 10);
|
||||
|
||||
const server = new GameServer({
|
||||
port: PORT,
|
||||
roomConfig: {
|
||||
maxPlayers: 16,
|
||||
tickRate: 20
|
||||
}
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
// Start server
|
||||
server.start().catch((err) => {
|
||||
console.error('[Main] 服务器启动失败 | Server failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 优雅关闭
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n[Main] 正在关闭服务器... | Shutting down server...');
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import { WsServer, type BaseConnection } from 'tsrpc';
|
||||
import { serviceProto, type ServiceType, type MsgInput } from '@esengine/network-protocols';
|
||||
import { Room, type IRoomConfig } from './Room';
|
||||
|
||||
/**
|
||||
* 服务器配置
|
||||
* Server configuration
|
||||
*/
|
||||
export interface IServerConfig {
|
||||
port: number;
|
||||
roomConfig?: Partial<IRoomConfig>;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: IServerConfig = {
|
||||
port: 3000
|
||||
};
|
||||
|
||||
/**
|
||||
* 游戏服务器
|
||||
* Game server
|
||||
*
|
||||
* 管理 WebSocket 连接和房间。
|
||||
* Manages WebSocket connections and rooms.
|
||||
*/
|
||||
export class GameServer {
|
||||
private _server: WsServer<ServiceType>;
|
||||
private _config: IServerConfig;
|
||||
private _rooms: Map<string, Room> = new Map();
|
||||
private _connectionToRoom: Map<BaseConnection<ServiceType>, { roomId: string; clientId: number }> = new Map();
|
||||
|
||||
constructor(config: Partial<IServerConfig> = {}) {
|
||||
this._config = { ...DEFAULT_CONFIG, ...config };
|
||||
this._server = new WsServer(serviceProto, {
|
||||
port: this._config.port,
|
||||
json: true,
|
||||
logLevel: 'info'
|
||||
});
|
||||
|
||||
this._setupHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动服务器
|
||||
* Start server
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
await this._server.start();
|
||||
console.log(`[GameServer] 服务器已启动 | Server started on port ${this._config.port}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止服务器
|
||||
* Stop server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
// 销毁所有房间
|
||||
// Destroy all rooms
|
||||
for (const room of this._rooms.values()) {
|
||||
room.destroy();
|
||||
}
|
||||
this._rooms.clear();
|
||||
this._connectionToRoom.clear();
|
||||
|
||||
await this._server.stop();
|
||||
console.log('[GameServer] 服务器已停止 | Server stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建房间
|
||||
* Get or create room
|
||||
*/
|
||||
getOrCreateRoom(roomId?: string): Room {
|
||||
// 如果没有指定房间 ID,寻找未满的房间或创建新房间
|
||||
// If no room ID specified, find a non-full room or create new one
|
||||
if (!roomId) {
|
||||
for (const room of this._rooms.values()) {
|
||||
if (!room.isFull) {
|
||||
return room;
|
||||
}
|
||||
}
|
||||
roomId = this._generateRoomId();
|
||||
}
|
||||
|
||||
let room = this._rooms.get(roomId);
|
||||
if (!room) {
|
||||
room = new Room(roomId, this._config.roomConfig);
|
||||
this._rooms.set(roomId, room);
|
||||
console.log(`[GameServer] 创建房间 | Room created: ${roomId}`);
|
||||
}
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取房间
|
||||
* Get room
|
||||
*/
|
||||
getRoom(roomId: string): Room | undefined {
|
||||
return this._rooms.get(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接的房间信息
|
||||
* Get connection's room info
|
||||
*/
|
||||
getConnectionInfo(connection: BaseConnection<ServiceType>): { roomId: string; clientId: number } | undefined {
|
||||
return this._connectionToRoom.get(connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置连接的房间信息
|
||||
* Set connection's room info
|
||||
*/
|
||||
setConnectionInfo(connection: BaseConnection<ServiceType>, roomId: string, clientId: number): void {
|
||||
this._connectionToRoom.set(connection, { roomId, clientId });
|
||||
}
|
||||
|
||||
private _setupHandlers(): void {
|
||||
// 处理加入请求
|
||||
// Handle join request
|
||||
this._server.implementApi('Join', async (call) => {
|
||||
const { playerName, roomId } = call.req;
|
||||
|
||||
const room = this.getOrCreateRoom(roomId);
|
||||
if (room.isFull) {
|
||||
call.error('房间已满 | Room is full');
|
||||
return;
|
||||
}
|
||||
|
||||
const player = room.addPlayer(playerName, call.conn);
|
||||
if (!player) {
|
||||
call.error('加入房间失败 | Failed to join room');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setConnectionInfo(call.conn, room.id, player.clientId);
|
||||
|
||||
// 向新玩家发送自己的生成消息
|
||||
// Send spawn message to new player for themselves
|
||||
call.conn.sendMsg('Spawn', {
|
||||
netId: player.netId,
|
||||
ownerId: player.clientId,
|
||||
prefab: 'player',
|
||||
pos: { x: 0, y: 0 },
|
||||
rot: 0
|
||||
});
|
||||
|
||||
call.succ({
|
||||
clientId: player.clientId,
|
||||
roomId: room.id,
|
||||
playerCount: room.playerCount
|
||||
});
|
||||
|
||||
console.log(`[GameServer] 玩家加入 | Player joined: ${playerName} (${player.clientId}) -> ${room.id}`);
|
||||
});
|
||||
|
||||
// 处理输入消息
|
||||
// Handle input message
|
||||
this._server.listenMsg('Input', (call) => {
|
||||
const info = this.getConnectionInfo(call.conn);
|
||||
if (!info) return;
|
||||
|
||||
const room = this.getRoom(info.roomId);
|
||||
if (!room) return;
|
||||
|
||||
const msg = call.msg as MsgInput;
|
||||
room.handleInput(info.clientId, msg.input);
|
||||
});
|
||||
|
||||
// 处理断开连接
|
||||
// Handle disconnect
|
||||
this._server.flows.postDisconnectFlow.push((v) => {
|
||||
const info = this._connectionToRoom.get(v.conn);
|
||||
if (info) {
|
||||
const room = this.getRoom(info.roomId);
|
||||
if (room) {
|
||||
room.removePlayer(info.clientId);
|
||||
console.log(`[GameServer] 玩家离开 | Player left: ${info.clientId} from ${info.roomId}`);
|
||||
|
||||
// 如果房间空了,删除房间
|
||||
// If room is empty, delete it
|
||||
if (room.playerCount === 0) {
|
||||
room.destroy();
|
||||
this._rooms.delete(info.roomId);
|
||||
console.log(`[GameServer] 删除空房间 | Empty room deleted: ${info.roomId}`);
|
||||
}
|
||||
}
|
||||
this._connectionToRoom.delete(v.conn);
|
||||
}
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
private _generateRoomId(): string {
|
||||
return Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import type { BaseConnection } from 'tsrpc';
|
||||
import type { ServiceType, IEntityState } from '@esengine/network-protocols';
|
||||
|
||||
/**
|
||||
* 连接类型别名
|
||||
* Connection type alias
|
||||
*/
|
||||
type Connection = BaseConnection<ServiceType>;
|
||||
|
||||
/**
|
||||
* 玩家信息
|
||||
* Player information
|
||||
*/
|
||||
export interface IPlayer {
|
||||
clientId: number;
|
||||
name: string;
|
||||
connection: Connection;
|
||||
netId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 房间配置
|
||||
* Room configuration
|
||||
*/
|
||||
export interface IRoomConfig {
|
||||
maxPlayers: number;
|
||||
tickRate: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: IRoomConfig = {
|
||||
maxPlayers: 16,
|
||||
tickRate: 20
|
||||
};
|
||||
|
||||
/**
|
||||
* 游戏房间
|
||||
* Game room
|
||||
*
|
||||
* 管理房间内的玩家和实体状态同步。
|
||||
* Manages players and entity state synchronization within a room.
|
||||
*/
|
||||
export class Room {
|
||||
private _id: string;
|
||||
private _config: IRoomConfig;
|
||||
private _players: Map<number, IPlayer> = new Map();
|
||||
private _entities: Map<number, IEntityState> = new Map();
|
||||
private _nextClientId: number = 1;
|
||||
private _nextNetId: number = 1;
|
||||
private _syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(id: string, config: Partial<IRoomConfig> = {}) {
|
||||
this._id = id;
|
||||
this._config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get playerCount(): number {
|
||||
return this._players.size;
|
||||
}
|
||||
|
||||
get isFull(): boolean {
|
||||
return this._players.size >= this._config.maxPlayers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加玩家
|
||||
* Add player
|
||||
*/
|
||||
addPlayer(name: string, connection: Connection): IPlayer | null {
|
||||
if (this.isFull) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clientId = this._nextClientId++;
|
||||
const netId = this._nextNetId++;
|
||||
|
||||
const player: IPlayer = {
|
||||
clientId,
|
||||
name,
|
||||
connection,
|
||||
netId
|
||||
};
|
||||
|
||||
this._players.set(clientId, player);
|
||||
|
||||
// 创建玩家实体
|
||||
// Create player entity
|
||||
const entityState: IEntityState = {
|
||||
netId,
|
||||
pos: { x: 0, y: 0 },
|
||||
rot: 0
|
||||
};
|
||||
this._entities.set(netId, entityState);
|
||||
|
||||
// 通知其他玩家
|
||||
// Notify other players
|
||||
this._broadcastSpawn(player, entityState);
|
||||
|
||||
// 同步现有实体给新玩家
|
||||
// Sync existing entities to new player
|
||||
this._syncExistingEntities(player);
|
||||
|
||||
// 启动同步循环
|
||||
// Start sync loop
|
||||
if (this._syncInterval === null) {
|
||||
this._startSyncLoop();
|
||||
}
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除玩家
|
||||
* Remove player
|
||||
*/
|
||||
removePlayer(clientId: number): void {
|
||||
const player = this._players.get(clientId);
|
||||
if (!player) return;
|
||||
|
||||
this._players.delete(clientId);
|
||||
this._entities.delete(player.netId);
|
||||
|
||||
// 通知其他玩家
|
||||
// Notify other players
|
||||
this._broadcastDespawn(player.netId);
|
||||
|
||||
// 停止同步循环
|
||||
// Stop sync loop
|
||||
if (this._players.size === 0 && this._syncInterval !== null) {
|
||||
clearInterval(this._syncInterval);
|
||||
this._syncInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理玩家输入
|
||||
* Handle player input
|
||||
*/
|
||||
handleInput(
|
||||
clientId: number,
|
||||
input: { moveDir?: { x: number; y: number }; actions?: string[] }
|
||||
): void {
|
||||
const player = this._players.get(clientId);
|
||||
if (!player) return;
|
||||
|
||||
const entity = this._entities.get(player.netId);
|
||||
if (!entity || !entity.pos) return;
|
||||
|
||||
// 简单的移动处理
|
||||
// Simple movement handling
|
||||
if (input.moveDir) {
|
||||
const speed = 5;
|
||||
entity.pos.x += input.moveDir.x * speed;
|
||||
entity.pos.y += input.moveDir.y * speed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取玩家
|
||||
* Get player
|
||||
*/
|
||||
getPlayer(clientId: number): IPlayer | undefined {
|
||||
return this._players.get(clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁房间
|
||||
* Destroy room
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this._syncInterval !== null) {
|
||||
clearInterval(this._syncInterval);
|
||||
this._syncInterval = null;
|
||||
}
|
||||
this._players.clear();
|
||||
this._entities.clear();
|
||||
}
|
||||
|
||||
private _startSyncLoop(): void {
|
||||
const interval = 1000 / this._config.tickRate;
|
||||
this._syncInterval = setInterval(() => {
|
||||
this._broadcastSync();
|
||||
}, interval);
|
||||
}
|
||||
|
||||
private _broadcastSync(): void {
|
||||
if (this._players.size === 0) return;
|
||||
|
||||
const entities = Array.from(this._entities.values());
|
||||
const time = Date.now();
|
||||
|
||||
for (const player of this._players.values()) {
|
||||
player.connection.sendMsg('Sync', { time, entities });
|
||||
}
|
||||
}
|
||||
|
||||
private _broadcastSpawn(newPlayer: IPlayer, state: IEntityState): void {
|
||||
for (const player of this._players.values()) {
|
||||
if (player.clientId === newPlayer.clientId) continue;
|
||||
|
||||
player.connection.sendMsg('Spawn', {
|
||||
netId: state.netId,
|
||||
ownerId: newPlayer.clientId,
|
||||
prefab: 'player',
|
||||
pos: state.pos ?? { x: 0, y: 0 },
|
||||
rot: state.rot ?? 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _broadcastDespawn(netId: number): void {
|
||||
for (const player of this._players.values()) {
|
||||
player.connection.sendMsg('Despawn', { netId });
|
||||
}
|
||||
}
|
||||
|
||||
private _syncExistingEntities(newPlayer: IPlayer): void {
|
||||
for (const [netId, state] of this._entities) {
|
||||
const owner = Array.from(this._players.values()).find((p) => p.netId === netId);
|
||||
if (!owner || owner.clientId === newPlayer.clientId) continue;
|
||||
|
||||
newPlayer.connection.sendMsg('Spawn', {
|
||||
netId,
|
||||
ownerId: owner.clientId,
|
||||
prefab: 'player',
|
||||
pos: state.pos ?? { x: 0, y: 0 },
|
||||
rot: state.rot ?? 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../network-ext/network-protocols"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts', 'src/main.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: ['tsrpc'],
|
||||
tsconfig: 'tsconfig.build.json'
|
||||
});
|
||||
20
packages/tools/create-esengine-server/CHANGELOG.md
Normal file
20
packages/tools/create-esengine-server/CHANGELOG.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# create-esengine-server
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#366](https://github.com/esengine/esengine/pull/366) [`b6f1235`](https://github.com/esengine/esengine/commit/b6f1235239c049abc62b6827554eb941e73dae65) Thanks [@esengine](https://github.com/esengine)! - feat(server): 添加游戏服务器框架与房间系统 | add game server framework with Room system
|
||||
|
||||
**@esengine/server** - 游戏服务器框架 | Game server framework
|
||||
- 文件路由系统 | File-based routing
|
||||
- Room 生命周期管理 (onCreate, onJoin, onLeave, onTick, onDispose) | Room lifecycle management
|
||||
- `@onMessage` 装饰器处理消息 | Message handler decorator
|
||||
- 玩家管理与断线处理 | Player management with auto-disconnect
|
||||
- 内置 JoinRoom/LeaveRoom API | Built-in room APIs
|
||||
- defineApi/defineMsg 类型安全辅助函数 | Type-safe helper functions
|
||||
|
||||
**create-esengine-server** - CLI 脚手架工具 | CLI scaffolding tool
|
||||
- 生成 shared/server/client 项目结构 | Creates project structure
|
||||
- 类型安全的协议定义 | Type-safe protocol definitions
|
||||
- 包含 GameRoom 示例实现 | Includes example implementation
|
||||
39
packages/tools/create-esengine-server/package.json
Normal file
39
packages/tools/create-esengine-server/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "create-esengine-server",
|
||||
"version": "1.1.0",
|
||||
"description": "Create ESEngine game server projects",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"create-esengine-server": "./dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"templates"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"prepublishOnly": "pnpm build"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^11.0.0",
|
||||
"prompts": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"keywords": [
|
||||
"esengine",
|
||||
"create",
|
||||
"game-server",
|
||||
"scaffold",
|
||||
"cli"
|
||||
]
|
||||
}
|
||||
545
packages/tools/create-esengine-server/src/index.ts
Normal file
545
packages/tools/create-esengine-server/src/index.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
import { Command } from 'commander'
|
||||
import prompts from 'prompts'
|
||||
import chalk from 'chalk'
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { execSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const VERSION = '1.0.0'
|
||||
|
||||
function printLogo(): void {
|
||||
console.log()
|
||||
console.log(chalk.cyan(' ╭──────────────────────────────────────╮'))
|
||||
console.log(chalk.cyan(' │ │'))
|
||||
console.log(chalk.cyan(' │ ') + chalk.bold.white('Create ESEngine Server') + chalk.cyan(' │'))
|
||||
console.log(chalk.cyan(' │ │'))
|
||||
console.log(chalk.cyan(' ╰──────────────────────────────────────╯'))
|
||||
console.log()
|
||||
}
|
||||
|
||||
function detectPackageManager(): 'pnpm' | 'yarn' | 'npm' {
|
||||
const userAgent = process.env.npm_config_user_agent || ''
|
||||
if (userAgent.includes('pnpm')) return 'pnpm'
|
||||
if (userAgent.includes('yarn')) return 'yarn'
|
||||
return 'npm'
|
||||
}
|
||||
|
||||
function getInstallCommand(pm: string): string {
|
||||
return pm === 'yarn' ? 'yarn' : `${pm} install`
|
||||
}
|
||||
|
||||
function writeFile(projectPath: string, relativePath: string, content: string): void {
|
||||
const fullPath = path.join(projectPath, relativePath)
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true })
|
||||
fs.writeFileSync(fullPath, content)
|
||||
}
|
||||
|
||||
function generateProject(projectPath: string, projectName: string): void {
|
||||
// ========================================================================
|
||||
// package.json
|
||||
// ========================================================================
|
||||
const packageJson = {
|
||||
name: projectName,
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
private: true,
|
||||
scripts: {
|
||||
dev: 'tsx watch src/server/main.ts',
|
||||
start: 'tsx src/server/main.ts',
|
||||
build: 'tsc',
|
||||
'build:start': 'tsc && node dist/server/main.js',
|
||||
},
|
||||
dependencies: {
|
||||
'@esengine/server': 'latest',
|
||||
'@esengine/rpc': 'latest',
|
||||
},
|
||||
devDependencies: {
|
||||
'@types/node': '^20.0.0',
|
||||
tsx: '^4.0.0',
|
||||
typescript: '^5.0.0',
|
||||
},
|
||||
}
|
||||
writeFile(projectPath, 'package.json', JSON.stringify(packageJson, null, 2))
|
||||
|
||||
// ========================================================================
|
||||
// tsconfig.json
|
||||
// ========================================================================
|
||||
const tsconfig = {
|
||||
compilerOptions: {
|
||||
target: 'ES2022',
|
||||
module: 'NodeNext',
|
||||
moduleResolution: 'NodeNext',
|
||||
lib: ['ES2022'],
|
||||
outDir: './dist',
|
||||
rootDir: './src',
|
||||
strict: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
forceConsistentCasingInFileNames: true,
|
||||
declaration: true,
|
||||
sourceMap: true,
|
||||
experimentalDecorators: true,
|
||||
emitDecoratorMetadata: true,
|
||||
},
|
||||
include: ['src/**/*'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
}
|
||||
writeFile(projectPath, 'tsconfig.json', JSON.stringify(tsconfig, null, 2))
|
||||
|
||||
// ========================================================================
|
||||
// src/shared/protocol.ts - 共享协议定义
|
||||
// ========================================================================
|
||||
const protocolTs = `/**
|
||||
* 游戏协议定义
|
||||
* Game Protocol Definition
|
||||
*
|
||||
* 这个文件定义了客户端和服务端共享的协议类型
|
||||
* This file defines protocol types shared between client and server
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// 房间 API | Room API
|
||||
// ============================================================================
|
||||
|
||||
/** 加入房间请求 | Join room request */
|
||||
export interface JoinRoomReq {
|
||||
roomType: string
|
||||
playerName: string
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 加入房间响应 | Join room response */
|
||||
export interface JoinRoomRes {
|
||||
roomId: string
|
||||
playerId: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 游戏消息 | Game Messages
|
||||
// ============================================================================
|
||||
|
||||
/** 移动消息 | Move message */
|
||||
export interface MsgMove {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/** 聊天消息 | Chat message */
|
||||
export interface MsgChat {
|
||||
text: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 服务端广播 | Server Broadcasts
|
||||
// ============================================================================
|
||||
|
||||
/** 玩家加入广播 | Player joined broadcast */
|
||||
export interface BroadcastJoined {
|
||||
playerId: string
|
||||
playerName: string
|
||||
}
|
||||
|
||||
/** 玩家离开广播 | Player left broadcast */
|
||||
export interface BroadcastLeft {
|
||||
playerId: string
|
||||
}
|
||||
|
||||
/** 状态同步广播 | State sync broadcast */
|
||||
export interface BroadcastSync {
|
||||
players: PlayerState[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 共享类型 | Shared Types
|
||||
// ============================================================================
|
||||
|
||||
/** 玩家状态 | Player state */
|
||||
export interface PlayerState {
|
||||
id: string
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
`
|
||||
writeFile(projectPath, 'src/shared/protocol.ts', protocolTs)
|
||||
|
||||
// ========================================================================
|
||||
// src/shared/index.ts
|
||||
// ========================================================================
|
||||
const sharedIndexTs = `export * from './protocol.js'
|
||||
`
|
||||
writeFile(projectPath, 'src/shared/index.ts', sharedIndexTs)
|
||||
|
||||
// ========================================================================
|
||||
// src/server/main.ts - 服务端入口
|
||||
// ========================================================================
|
||||
const serverMainTs = `import { createServer } from '@esengine/server'
|
||||
import { GameRoom } from './rooms/GameRoom.js'
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3000
|
||||
|
||||
async function main() {
|
||||
const server = await createServer({
|
||||
port: PORT,
|
||||
onConnect(conn) {
|
||||
console.log('[Server] Client connected:', conn.id)
|
||||
},
|
||||
onDisconnect(conn) {
|
||||
console.log('[Server] Client disconnected:', conn.id)
|
||||
},
|
||||
})
|
||||
|
||||
// 注册房间类型
|
||||
server.define('game', GameRoom)
|
||||
|
||||
await server.start()
|
||||
|
||||
console.log('========================================')
|
||||
console.log(' ${projectName}')
|
||||
console.log('========================================')
|
||||
console.log(\` WebSocket: ws://localhost:\${PORT}\`)
|
||||
console.log(' Room type: "game"')
|
||||
console.log(' Press Ctrl+C to stop')
|
||||
console.log('========================================')
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\\nShutting down...')
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
main().catch(console.error)
|
||||
`
|
||||
writeFile(projectPath, 'src/server/main.ts', serverMainTs)
|
||||
|
||||
// ========================================================================
|
||||
// src/server/rooms/GameRoom.ts - 游戏房间
|
||||
// ========================================================================
|
||||
const gameRoomTs = `import { Room, Player, onMessage } from '@esengine/server'
|
||||
import type {
|
||||
MsgMove,
|
||||
MsgChat,
|
||||
PlayerState,
|
||||
BroadcastSync,
|
||||
BroadcastJoined,
|
||||
BroadcastLeft,
|
||||
} from '../../shared/index.js'
|
||||
|
||||
/** 玩家数据 | Player data */
|
||||
interface PlayerData {
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 游戏房间
|
||||
* Game Room
|
||||
*/
|
||||
export class GameRoom extends Room<{ players: PlayerState[] }, PlayerData> {
|
||||
// 配置
|
||||
maxPlayers = 8
|
||||
tickRate = 20
|
||||
|
||||
// 状态
|
||||
state = {
|
||||
players: [] as PlayerState[],
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 生命周期 | Lifecycle
|
||||
// ========================================================================
|
||||
|
||||
onCreate() {
|
||||
console.log(\`[GameRoom] 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.state.players.push({
|
||||
id: player.id,
|
||||
name: player.data.name,
|
||||
x: player.data.x,
|
||||
y: player.data.y,
|
||||
})
|
||||
|
||||
// 广播玩家加入
|
||||
this.broadcast<BroadcastJoined>('Joined', {
|
||||
playerId: player.id,
|
||||
playerName: player.data.name,
|
||||
})
|
||||
|
||||
console.log(\`[GameRoom] \${player.data.name} joined room \${this.id}\`)
|
||||
}
|
||||
|
||||
onLeave(player: Player<PlayerData>) {
|
||||
// 从状态移除
|
||||
this.state.players = this.state.players.filter(p => p.id !== player.id)
|
||||
|
||||
// 广播玩家离开
|
||||
this.broadcast<BroadcastLeft>('Left', {
|
||||
playerId: player.id,
|
||||
})
|
||||
|
||||
console.log(\`[GameRoom] \${player.data.name} left room \${this.id}\`)
|
||||
}
|
||||
|
||||
onTick(_dt: number) {
|
||||
// 广播状态同步
|
||||
this.broadcast<BroadcastSync>('Sync', {
|
||||
players: this.state.players,
|
||||
})
|
||||
}
|
||||
|
||||
onDispose() {
|
||||
console.log(\`[GameRoom] Room \${this.id} disposed\`)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 消息处理 | Message Handlers
|
||||
// ========================================================================
|
||||
|
||||
@onMessage('Move')
|
||||
handleMove(data: MsgMove, player: Player<PlayerData>) {
|
||||
player.data.x = data.x
|
||||
player.data.y = data.y
|
||||
|
||||
// 更新状态
|
||||
const p = this.state.players.find(p => p.id === player.id)
|
||||
if (p) {
|
||||
p.x = data.x
|
||||
p.y = data.y
|
||||
}
|
||||
}
|
||||
|
||||
@onMessage('Chat')
|
||||
handleChat(data: MsgChat, player: Player<PlayerData>) {
|
||||
// 广播聊天消息
|
||||
this.broadcast('Chat', {
|
||||
from: player.data.name,
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
`
|
||||
writeFile(projectPath, 'src/server/rooms/GameRoom.ts', gameRoomTs)
|
||||
|
||||
// ========================================================================
|
||||
// src/client/index.ts - 客户端示例
|
||||
// ========================================================================
|
||||
const clientIndexTs = `/**
|
||||
* 客户端示例代码
|
||||
* Client Example Code
|
||||
*
|
||||
* 这是一个示例,展示如何从客户端连接服务器
|
||||
* This is an example showing how to connect to the server from client
|
||||
*/
|
||||
|
||||
import { connect } from '@esengine/rpc/client'
|
||||
import type {
|
||||
JoinRoomReq,
|
||||
JoinRoomRes,
|
||||
MsgMove,
|
||||
BroadcastSync,
|
||||
BroadcastJoined,
|
||||
} from '../shared/index.js'
|
||||
|
||||
async function main() {
|
||||
// 连接服务器
|
||||
const client = await connect('ws://localhost:3000')
|
||||
|
||||
// 加入房间
|
||||
const result = await client.call<JoinRoomReq, JoinRoomRes>('JoinRoom', {
|
||||
roomType: 'game',
|
||||
playerName: 'Alice',
|
||||
})
|
||||
console.log('Joined room:', result.roomId)
|
||||
|
||||
// 监听广播
|
||||
client.onMessage<BroadcastJoined>('Joined', (data) => {
|
||||
console.log('Player joined:', data.playerName)
|
||||
})
|
||||
|
||||
client.onMessage<BroadcastSync>('Sync', (data) => {
|
||||
console.log('State update:', data.players.length, 'players')
|
||||
})
|
||||
|
||||
// 发送移动消息
|
||||
client.send<MsgMove>('RoomMessage', {
|
||||
type: 'Move',
|
||||
payload: { x: 100, y: 200 },
|
||||
})
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
`
|
||||
writeFile(projectPath, 'src/client/index.ts', clientIndexTs)
|
||||
|
||||
// ========================================================================
|
||||
// .gitignore
|
||||
// ========================================================================
|
||||
const gitignore = `node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
`
|
||||
writeFile(projectPath, '.gitignore', gitignore)
|
||||
|
||||
// ========================================================================
|
||||
// README.md
|
||||
// ========================================================================
|
||||
const readme = `# ${projectName}
|
||||
|
||||
ESEngine 游戏服务器项目。
|
||||
|
||||
## 项目结构
|
||||
|
||||
\`\`\`
|
||||
src/
|
||||
├── shared/ # 共享协议(客户端服务端都用)
|
||||
│ ├── protocol.ts # 类型定义
|
||||
│ └── index.ts
|
||||
├── server/ # 服务端
|
||||
│ ├── main.ts # 入口
|
||||
│ └── rooms/
|
||||
│ └── GameRoom.ts # 游戏房间
|
||||
└── client/ # 客户端示例
|
||||
└── index.ts
|
||||
\`\`\`
|
||||
|
||||
## 快速开始
|
||||
|
||||
\`\`\`bash
|
||||
# 启动服务器
|
||||
npm run dev
|
||||
|
||||
# 服务器将在 ws://localhost:3000 启动
|
||||
\`\`\`
|
||||
|
||||
## 客户端连接
|
||||
|
||||
\`\`\`typescript
|
||||
import { connect } from '@esengine/rpc/client'
|
||||
|
||||
const client = await connect('ws://localhost:3000')
|
||||
|
||||
// 加入房间
|
||||
const { roomId } = await client.call('JoinRoom', {
|
||||
roomType: 'game',
|
||||
playerName: 'Alice',
|
||||
})
|
||||
|
||||
// 监听同步
|
||||
client.onMessage('Sync', (state) => {
|
||||
console.log(state.players)
|
||||
})
|
||||
|
||||
// 发送消息
|
||||
client.send('RoomMessage', { type: 'Move', payload: { x: 100, y: 200 } })
|
||||
\`\`\`
|
||||
`
|
||||
writeFile(projectPath, 'README.md', readme)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
printLogo()
|
||||
|
||||
const program = new Command()
|
||||
|
||||
program
|
||||
.name('create-esengine-server')
|
||||
.description('Create a new ESEngine game server project')
|
||||
.version(VERSION)
|
||||
.argument('[project-name]', 'Name of the project')
|
||||
.action(async (projectName?: string) => {
|
||||
if (!projectName) {
|
||||
const response = await prompts({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'Project name:',
|
||||
initial: 'my-game-server',
|
||||
}, {
|
||||
onCancel: () => {
|
||||
console.log(chalk.yellow('\n Cancelled.'))
|
||||
process.exit(0)
|
||||
},
|
||||
})
|
||||
projectName = response.name
|
||||
}
|
||||
|
||||
if (!projectName) {
|
||||
console.log(chalk.red(' Project name is required.'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const projectPath = path.resolve(process.cwd(), projectName)
|
||||
|
||||
if (fs.existsSync(projectPath)) {
|
||||
const files = fs.readdirSync(projectPath)
|
||||
if (files.length > 0) {
|
||||
const response = await prompts({
|
||||
type: 'confirm',
|
||||
name: 'overwrite',
|
||||
message: `Directory "${projectName}" is not empty. Continue?`,
|
||||
initial: false,
|
||||
})
|
||||
if (!response.overwrite) {
|
||||
console.log(chalk.yellow('\n Cancelled.'))
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fs.mkdirSync(projectPath, { recursive: true })
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log(chalk.bold(` Creating project in ${chalk.cyan(projectPath)}...`))
|
||||
console.log()
|
||||
|
||||
generateProject(projectPath, projectName)
|
||||
console.log(chalk.green(' ✓ Created project files'))
|
||||
|
||||
const pm = detectPackageManager()
|
||||
const installCmd = getInstallCommand(pm)
|
||||
|
||||
console.log(chalk.gray(` Running ${installCmd}...`))
|
||||
console.log()
|
||||
|
||||
try {
|
||||
execSync(installCmd, { cwd: projectPath, stdio: 'inherit' })
|
||||
console.log()
|
||||
console.log(chalk.green(' ✓ Dependencies installed'))
|
||||
} catch {
|
||||
console.log(chalk.yellow(`\n ⚠ Failed to install. Run "${installCmd}" manually.`))
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log(chalk.bold(' Done! Next steps:'))
|
||||
console.log()
|
||||
console.log(chalk.cyan(` cd ${projectName}`))
|
||||
console.log(chalk.cyan(` ${pm} run dev`))
|
||||
console.log()
|
||||
console.log(chalk.gray(' Project structure:'))
|
||||
console.log(chalk.gray(' src/'))
|
||||
console.log(chalk.gray(' ├── shared/ # Shared protocol types'))
|
||||
console.log(chalk.gray(' │ └── protocol.ts'))
|
||||
console.log(chalk.gray(' ├── server/ # Server code'))
|
||||
console.log(chalk.gray(' │ ├── main.ts'))
|
||||
console.log(chalk.gray(' │ └── rooms/'))
|
||||
console.log(chalk.gray(' │ └── GameRoom.ts'))
|
||||
console.log(chalk.gray(' └── client/ # Client example'))
|
||||
console.log(chalk.gray(' └── index.ts'))
|
||||
console.log()
|
||||
})
|
||||
|
||||
program.parse()
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
9
packages/tools/create-esengine-server/tsconfig.json
Normal file
9
packages/tools/create-esengine-server/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "templates"]
|
||||
}
|
||||
13
packages/tools/create-esengine-server/tsup.config.ts
Normal file
13
packages/tools/create-esengine-server/tsup.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: false,
|
||||
clean: true,
|
||||
sourcemap: false,
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
external: ['chalk', 'commander', 'prompts'],
|
||||
})
|
||||
50
pnpm-lock.yaml
generated
50
pnpm-lock.yaml
generated
@@ -1679,6 +1679,31 @@ importers:
|
||||
specifier: ^1.11.0
|
||||
version: 1.11.8
|
||||
|
||||
packages/framework/server:
|
||||
dependencies:
|
||||
'@esengine/rpc':
|
||||
specifier: workspace:*
|
||||
version: link:../rpc
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.27
|
||||
'@types/ws':
|
||||
specifier: ^8.5.13
|
||||
version: 8.18.1
|
||||
rimraf:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.10
|
||||
tsup:
|
||||
specifier: ^8.0.0
|
||||
version: 8.5.1(@microsoft/api-extractor@7.55.2(@types/node@20.19.27))(@swc/core@1.15.7(@swc/helpers@0.5.18))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
ws:
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.3
|
||||
|
||||
packages/framework/spatial:
|
||||
dependencies:
|
||||
tslib:
|
||||
@@ -2100,6 +2125,31 @@ importers:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/tools/create-esengine-server:
|
||||
dependencies:
|
||||
chalk:
|
||||
specifier: ^5.3.0
|
||||
version: 5.6.2
|
||||
commander:
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.0
|
||||
prompts:
|
||||
specifier: ^2.4.2
|
||||
version: 2.4.2
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.27
|
||||
'@types/prompts':
|
||||
specifier: ^2.4.9
|
||||
version: 2.4.9
|
||||
tsup:
|
||||
specifier: ^8.0.0
|
||||
version: 8.5.1(@microsoft/api-extractor@7.55.2(@types/node@20.19.27))(@swc/core@1.15.7(@swc/helpers@0.5.18))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/tools/demos:
|
||||
dependencies:
|
||||
'@esengine/fsm':
|
||||
|
||||
Reference in New Issue
Block a user