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

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

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

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

View File

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

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'],
})