Compare commits

...

10 Commits

Author SHA1 Message Date
github-actions[bot]
838cda91aa chore: release packages (#375)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 14:11:27 +08:00
YHH
a000cc07d7 feat(rpc): export RpcClient from main entry point (#374) 2025-12-28 14:09:16 +08:00
github-actions[bot]
1316d7de49 chore: release packages (#373)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 13:49:34 +08:00
YHH
9c41181875 fix(server): expose id property on ServerConnection type (#372) 2025-12-28 13:47:27 +08:00
github-actions[bot]
9f3f9a547a chore: release packages (#371)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 13:32:44 +08:00
YHH
18df9d1cda fix(server): allow define() to be called before start() (#370) 2025-12-28 13:29:17 +08:00
github-actions[bot]
9a4b3388e0 chore: release packages (#369)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 12:51:20 +08:00
YHH
66d5dc27f7 fix(server): 修复发布缺少 dist | fix missing dist (#368)
* fix(server): 修复发布缺少 dist | fix missing dist in publish

* ci: 添加 server 和 create-esengine-server 构建 | add server packages to build

* fix: 添加 create-esengine-server 到 changeset
2025-12-28 12:46:50 +08:00
github-actions[bot]
8a3e54cb45 chore: release packages (#367)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 12:25:51 +08:00
YHH
b6f1235239 feat(server): 添加游戏服务器框架 | add game server framework (#366)
**@esengine/server** - 游戏服务器框架 | Game server framework
- 文件路由系统 | File-based routing
- Room 生命周期 (onCreate, onJoin, onLeave, onTick, onDispose)
- @onMessage 装饰器 | Message handler decorator
- 玩家管理与断线处理 | Player management with auto-disconnect
- 内置 JoinRoom/LeaveRoom API | Built-in room APIs
- defineApi/defineMsg 类型安全辅助函数 | Type-safe helpers

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

**其他 | Other**
- 删除旧的 network-server 包 | Remove old network-server
- 更新服务器文档 | Update server documentation
2025-12-28 12:23:55 +08:00
38 changed files with 2886 additions and 826 deletions

View File

@@ -56,7 +56,9 @@ jobs:
pnpm --filter "@esengine/network-protocols" build
pnpm --filter "@esengine/rpc" build
pnpm --filter "@esengine/network" build
pnpm --filter "@esengine/server" build
pnpm --filter "@esengine/cli" build
pnpm --filter "create-esengine-server" build
- name: Create Release Pull Request or Publish
id: changesets

View File

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

View File

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

View File

@@ -1,5 +1,12 @@
# @esengine/network
## 2.1.1
### Patch Changes
- Updated dependencies [[`a000cc0`](https://github.com/esengine/esengine/commit/a000cc07d7cebe8ccbfa983fde610296bfba2f1b)]:
- @esengine/rpc@1.1.1
## 2.1.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/network",
"version": "2.1.0",
"version": "2.1.1",
"description": "Network synchronization for multiplayer games",
"esengine": {
"plugin": true,

View File

@@ -1,5 +1,21 @@
# @esengine/rpc
## 1.1.1
### Patch Changes
- [#374](https://github.com/esengine/esengine/pull/374) [`a000cc0`](https://github.com/esengine/esengine/commit/a000cc07d7cebe8ccbfa983fde610296bfba2f1b) Thanks [@esengine](https://github.com/esengine)! - feat: export RpcClient and connect from main entry point
Re-export `RpcClient`, `connect`, and related types from the main entry point for better compatibility with bundlers (Cocos Creator, Vite, etc.) that may have issues with subpath exports.
```typescript
// Now works in all environments:
import { rpc, RpcClient, connect } from '@esengine/rpc';
// Subpath import still supported:
import { RpcClient } from '@esengine/rpc/client';
```
## 1.1.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/rpc",
"version": "1.1.0",
"version": "1.1.1",
"description": "Elegant type-safe RPC library for ESEngine",
"type": "module",
"main": "./dist/index.js",

View File

@@ -40,3 +40,7 @@
export { rpc } from './define'
export * from './types'
// Re-export client for browser/bundler compatibility
export { RpcClient, connect } from './client/index'
export type { RpcClientOptions, WebSocketAdapter, WebSocketFactory } from './client/index'

View File

@@ -0,0 +1,55 @@
# @esengine/server
## 1.1.4
### Patch Changes
- Updated dependencies [[`a000cc0`](https://github.com/esengine/esengine/commit/a000cc07d7cebe8ccbfa983fde610296bfba2f1b)]:
- @esengine/rpc@1.1.1
## 1.1.3
### Patch Changes
- [#372](https://github.com/esengine/esengine/pull/372) [`9c41181`](https://github.com/esengine/esengine/commit/9c4118187539e39ead48ef2fa7af3ff45285fde5) Thanks [@esengine](https://github.com/esengine)! - fix: expose `id` property on ServerConnection type
TypeScript was not properly resolving the inherited `id` property from the base `Connection` interface in some module resolution scenarios. This fix explicitly declares the `id` property on `ServerConnection` to ensure it's always visible to consumers.
## 1.1.2
### Patch Changes
- [#370](https://github.com/esengine/esengine/pull/370) [`18df9d1`](https://github.com/esengine/esengine/commit/18df9d1cda4d4cf3095841d93125f9d41ce214f1) Thanks [@esengine](https://github.com/esengine)! - fix: allow define() to be called before start()
Previously, calling `server.define()` before `server.start()` would throw an error because `roomManager` was initialized inside `start()`. This fix moves the `roomManager` initialization to `createServer()`, allowing the expected usage pattern:
```typescript
const server = await createServer({ port: 3000 });
server.define('world', WorldRoom); // Now works correctly
await server.start();
```
## 1.1.1
### Patch Changes
- [#368](https://github.com/esengine/esengine/pull/368) [`66d5dc2`](https://github.com/esengine/esengine/commit/66d5dc27f740cc81b0645bde61dabf665743a5a0) Thanks [@esengine](https://github.com/esengine)! - fix: 修复发布缺少 dist 目录 | fix missing dist in published packages
## 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

View 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"
}

View File

@@ -0,0 +1,50 @@
{
"name": "@esengine/server",
"version": "1.1.4",
"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"
]
}

View File

@@ -0,0 +1,253 @@
/**
* @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
// 房间管理器(立即初始化,以便 define() 可在 start() 前调用)
const roomManager = new RoomManager((conn, type, data) => {
rpcServer?.send(conn, 'RoomMessage' as any, { type, data } as any)
})
// 构建 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
} = {
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 {
roomManager.define(name, roomClass as RoomClass)
},
async start() {
// 构建 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
}

View 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
}

View 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'

View 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)
}
}

View 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 })
}

View 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++}`
}
}

View 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)
}
}

View 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'

View 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
}

View File

@@ -0,0 +1,234 @@
/**
* @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 连接唯一标识(继承自 Connection
* @en Connection unique identifier (inherited from Connection)
*/
readonly id: string
/**
* @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>
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View 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,
})

View File

@@ -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 兼容性问题

View File

@@ -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"
}

View File

@@ -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';

View File

@@ -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);
});

View File

@@ -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();
}
}

View File

@@ -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
});
}
}
}

View File

@@ -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"]
}

View File

@@ -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"
}
]
}

View File

@@ -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'
});

View File

@@ -0,0 +1,26 @@
# create-esengine-server
## 1.1.1
### Patch Changes
- [#368](https://github.com/esengine/esengine/pull/368) [`66d5dc2`](https://github.com/esengine/esengine/commit/66d5dc27f740cc81b0645bde61dabf665743a5a0) Thanks [@esengine](https://github.com/esengine)! - fix: 修复发布缺少 dist 目录 | fix missing dist in published packages
## 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

View File

@@ -0,0 +1,39 @@
{
"name": "create-esengine-server",
"version": "1.1.1",
"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"
]
}

View 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)

View File

@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "templates"]
}

View 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
View File

@@ -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':