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:
39
packages/tools/create-esengine-server/package.json
Normal file
39
packages/tools/create-esengine-server/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "create-esengine-server",
|
||||
"version": "1.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"
|
||||
]
|
||||
}
|
||||
545
packages/tools/create-esengine-server/src/index.ts
Normal file
545
packages/tools/create-esengine-server/src/index.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
import { Command } from 'commander'
|
||||
import prompts from 'prompts'
|
||||
import chalk from 'chalk'
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { execSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const VERSION = '1.0.0'
|
||||
|
||||
function printLogo(): void {
|
||||
console.log()
|
||||
console.log(chalk.cyan(' ╭──────────────────────────────────────╮'))
|
||||
console.log(chalk.cyan(' │ │'))
|
||||
console.log(chalk.cyan(' │ ') + chalk.bold.white('Create ESEngine Server') + chalk.cyan(' │'))
|
||||
console.log(chalk.cyan(' │ │'))
|
||||
console.log(chalk.cyan(' ╰──────────────────────────────────────╯'))
|
||||
console.log()
|
||||
}
|
||||
|
||||
function detectPackageManager(): 'pnpm' | 'yarn' | 'npm' {
|
||||
const userAgent = process.env.npm_config_user_agent || ''
|
||||
if (userAgent.includes('pnpm')) return 'pnpm'
|
||||
if (userAgent.includes('yarn')) return 'yarn'
|
||||
return 'npm'
|
||||
}
|
||||
|
||||
function getInstallCommand(pm: string): string {
|
||||
return pm === 'yarn' ? 'yarn' : `${pm} install`
|
||||
}
|
||||
|
||||
function writeFile(projectPath: string, relativePath: string, content: string): void {
|
||||
const fullPath = path.join(projectPath, relativePath)
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true })
|
||||
fs.writeFileSync(fullPath, content)
|
||||
}
|
||||
|
||||
function generateProject(projectPath: string, projectName: string): void {
|
||||
// ========================================================================
|
||||
// package.json
|
||||
// ========================================================================
|
||||
const packageJson = {
|
||||
name: projectName,
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
private: true,
|
||||
scripts: {
|
||||
dev: 'tsx watch src/server/main.ts',
|
||||
start: 'tsx src/server/main.ts',
|
||||
build: 'tsc',
|
||||
'build:start': 'tsc && node dist/server/main.js',
|
||||
},
|
||||
dependencies: {
|
||||
'@esengine/server': 'latest',
|
||||
'@esengine/rpc': 'latest',
|
||||
},
|
||||
devDependencies: {
|
||||
'@types/node': '^20.0.0',
|
||||
tsx: '^4.0.0',
|
||||
typescript: '^5.0.0',
|
||||
},
|
||||
}
|
||||
writeFile(projectPath, 'package.json', JSON.stringify(packageJson, null, 2))
|
||||
|
||||
// ========================================================================
|
||||
// tsconfig.json
|
||||
// ========================================================================
|
||||
const tsconfig = {
|
||||
compilerOptions: {
|
||||
target: 'ES2022',
|
||||
module: 'NodeNext',
|
||||
moduleResolution: 'NodeNext',
|
||||
lib: ['ES2022'],
|
||||
outDir: './dist',
|
||||
rootDir: './src',
|
||||
strict: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
forceConsistentCasingInFileNames: true,
|
||||
declaration: true,
|
||||
sourceMap: true,
|
||||
experimentalDecorators: true,
|
||||
emitDecoratorMetadata: true,
|
||||
},
|
||||
include: ['src/**/*'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
}
|
||||
writeFile(projectPath, 'tsconfig.json', JSON.stringify(tsconfig, null, 2))
|
||||
|
||||
// ========================================================================
|
||||
// src/shared/protocol.ts - 共享协议定义
|
||||
// ========================================================================
|
||||
const protocolTs = `/**
|
||||
* 游戏协议定义
|
||||
* Game Protocol Definition
|
||||
*
|
||||
* 这个文件定义了客户端和服务端共享的协议类型
|
||||
* This file defines protocol types shared between client and server
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// 房间 API | Room API
|
||||
// ============================================================================
|
||||
|
||||
/** 加入房间请求 | Join room request */
|
||||
export interface JoinRoomReq {
|
||||
roomType: string
|
||||
playerName: string
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 加入房间响应 | Join room response */
|
||||
export interface JoinRoomRes {
|
||||
roomId: string
|
||||
playerId: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 游戏消息 | Game Messages
|
||||
// ============================================================================
|
||||
|
||||
/** 移动消息 | Move message */
|
||||
export interface MsgMove {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/** 聊天消息 | Chat message */
|
||||
export interface MsgChat {
|
||||
text: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 服务端广播 | Server Broadcasts
|
||||
// ============================================================================
|
||||
|
||||
/** 玩家加入广播 | Player joined broadcast */
|
||||
export interface BroadcastJoined {
|
||||
playerId: string
|
||||
playerName: string
|
||||
}
|
||||
|
||||
/** 玩家离开广播 | Player left broadcast */
|
||||
export interface BroadcastLeft {
|
||||
playerId: string
|
||||
}
|
||||
|
||||
/** 状态同步广播 | State sync broadcast */
|
||||
export interface BroadcastSync {
|
||||
players: PlayerState[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 共享类型 | Shared Types
|
||||
// ============================================================================
|
||||
|
||||
/** 玩家状态 | Player state */
|
||||
export interface PlayerState {
|
||||
id: string
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
`
|
||||
writeFile(projectPath, 'src/shared/protocol.ts', protocolTs)
|
||||
|
||||
// ========================================================================
|
||||
// src/shared/index.ts
|
||||
// ========================================================================
|
||||
const sharedIndexTs = `export * from './protocol.js'
|
||||
`
|
||||
writeFile(projectPath, 'src/shared/index.ts', sharedIndexTs)
|
||||
|
||||
// ========================================================================
|
||||
// src/server/main.ts - 服务端入口
|
||||
// ========================================================================
|
||||
const serverMainTs = `import { createServer } from '@esengine/server'
|
||||
import { GameRoom } from './rooms/GameRoom.js'
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3000
|
||||
|
||||
async function main() {
|
||||
const server = await createServer({
|
||||
port: PORT,
|
||||
onConnect(conn) {
|
||||
console.log('[Server] Client connected:', conn.id)
|
||||
},
|
||||
onDisconnect(conn) {
|
||||
console.log('[Server] Client disconnected:', conn.id)
|
||||
},
|
||||
})
|
||||
|
||||
// 注册房间类型
|
||||
server.define('game', GameRoom)
|
||||
|
||||
await server.start()
|
||||
|
||||
console.log('========================================')
|
||||
console.log(' ${projectName}')
|
||||
console.log('========================================')
|
||||
console.log(\` WebSocket: ws://localhost:\${PORT}\`)
|
||||
console.log(' Room type: "game"')
|
||||
console.log(' Press Ctrl+C to stop')
|
||||
console.log('========================================')
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\\nShutting down...')
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
main().catch(console.error)
|
||||
`
|
||||
writeFile(projectPath, 'src/server/main.ts', serverMainTs)
|
||||
|
||||
// ========================================================================
|
||||
// src/server/rooms/GameRoom.ts - 游戏房间
|
||||
// ========================================================================
|
||||
const gameRoomTs = `import { Room, Player, onMessage } from '@esengine/server'
|
||||
import type {
|
||||
MsgMove,
|
||||
MsgChat,
|
||||
PlayerState,
|
||||
BroadcastSync,
|
||||
BroadcastJoined,
|
||||
BroadcastLeft,
|
||||
} from '../../shared/index.js'
|
||||
|
||||
/** 玩家数据 | Player data */
|
||||
interface PlayerData {
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 游戏房间
|
||||
* Game Room
|
||||
*/
|
||||
export class GameRoom extends Room<{ players: PlayerState[] }, PlayerData> {
|
||||
// 配置
|
||||
maxPlayers = 8
|
||||
tickRate = 20
|
||||
|
||||
// 状态
|
||||
state = {
|
||||
players: [] as PlayerState[],
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 生命周期 | Lifecycle
|
||||
// ========================================================================
|
||||
|
||||
onCreate() {
|
||||
console.log(\`[GameRoom] Room \${this.id} created\`)
|
||||
}
|
||||
|
||||
onJoin(player: Player<PlayerData>) {
|
||||
// 初始化玩家数据
|
||||
player.data.name = 'Player_' + player.id.slice(-4)
|
||||
player.data.x = Math.random() * 800
|
||||
player.data.y = Math.random() * 600
|
||||
|
||||
// 添加到状态
|
||||
this.state.players.push({
|
||||
id: player.id,
|
||||
name: player.data.name,
|
||||
x: player.data.x,
|
||||
y: player.data.y,
|
||||
})
|
||||
|
||||
// 广播玩家加入
|
||||
this.broadcast<BroadcastJoined>('Joined', {
|
||||
playerId: player.id,
|
||||
playerName: player.data.name,
|
||||
})
|
||||
|
||||
console.log(\`[GameRoom] \${player.data.name} joined room \${this.id}\`)
|
||||
}
|
||||
|
||||
onLeave(player: Player<PlayerData>) {
|
||||
// 从状态移除
|
||||
this.state.players = this.state.players.filter(p => p.id !== player.id)
|
||||
|
||||
// 广播玩家离开
|
||||
this.broadcast<BroadcastLeft>('Left', {
|
||||
playerId: player.id,
|
||||
})
|
||||
|
||||
console.log(\`[GameRoom] \${player.data.name} left room \${this.id}\`)
|
||||
}
|
||||
|
||||
onTick(_dt: number) {
|
||||
// 广播状态同步
|
||||
this.broadcast<BroadcastSync>('Sync', {
|
||||
players: this.state.players,
|
||||
})
|
||||
}
|
||||
|
||||
onDispose() {
|
||||
console.log(\`[GameRoom] Room \${this.id} disposed\`)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 消息处理 | Message Handlers
|
||||
// ========================================================================
|
||||
|
||||
@onMessage('Move')
|
||||
handleMove(data: MsgMove, player: Player<PlayerData>) {
|
||||
player.data.x = data.x
|
||||
player.data.y = data.y
|
||||
|
||||
// 更新状态
|
||||
const p = this.state.players.find(p => p.id === player.id)
|
||||
if (p) {
|
||||
p.x = data.x
|
||||
p.y = data.y
|
||||
}
|
||||
}
|
||||
|
||||
@onMessage('Chat')
|
||||
handleChat(data: MsgChat, player: Player<PlayerData>) {
|
||||
// 广播聊天消息
|
||||
this.broadcast('Chat', {
|
||||
from: player.data.name,
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
`
|
||||
writeFile(projectPath, 'src/server/rooms/GameRoom.ts', gameRoomTs)
|
||||
|
||||
// ========================================================================
|
||||
// src/client/index.ts - 客户端示例
|
||||
// ========================================================================
|
||||
const clientIndexTs = `/**
|
||||
* 客户端示例代码
|
||||
* Client Example Code
|
||||
*
|
||||
* 这是一个示例,展示如何从客户端连接服务器
|
||||
* This is an example showing how to connect to the server from client
|
||||
*/
|
||||
|
||||
import { connect } from '@esengine/rpc/client'
|
||||
import type {
|
||||
JoinRoomReq,
|
||||
JoinRoomRes,
|
||||
MsgMove,
|
||||
BroadcastSync,
|
||||
BroadcastJoined,
|
||||
} from '../shared/index.js'
|
||||
|
||||
async function main() {
|
||||
// 连接服务器
|
||||
const client = await connect('ws://localhost:3000')
|
||||
|
||||
// 加入房间
|
||||
const result = await client.call<JoinRoomReq, JoinRoomRes>('JoinRoom', {
|
||||
roomType: 'game',
|
||||
playerName: 'Alice',
|
||||
})
|
||||
console.log('Joined room:', result.roomId)
|
||||
|
||||
// 监听广播
|
||||
client.onMessage<BroadcastJoined>('Joined', (data) => {
|
||||
console.log('Player joined:', data.playerName)
|
||||
})
|
||||
|
||||
client.onMessage<BroadcastSync>('Sync', (data) => {
|
||||
console.log('State update:', data.players.length, 'players')
|
||||
})
|
||||
|
||||
// 发送移动消息
|
||||
client.send<MsgMove>('RoomMessage', {
|
||||
type: 'Move',
|
||||
payload: { x: 100, y: 200 },
|
||||
})
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
`
|
||||
writeFile(projectPath, 'src/client/index.ts', clientIndexTs)
|
||||
|
||||
// ========================================================================
|
||||
// .gitignore
|
||||
// ========================================================================
|
||||
const gitignore = `node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
`
|
||||
writeFile(projectPath, '.gitignore', gitignore)
|
||||
|
||||
// ========================================================================
|
||||
// README.md
|
||||
// ========================================================================
|
||||
const readme = `# ${projectName}
|
||||
|
||||
ESEngine 游戏服务器项目。
|
||||
|
||||
## 项目结构
|
||||
|
||||
\`\`\`
|
||||
src/
|
||||
├── shared/ # 共享协议(客户端服务端都用)
|
||||
│ ├── protocol.ts # 类型定义
|
||||
│ └── index.ts
|
||||
├── server/ # 服务端
|
||||
│ ├── main.ts # 入口
|
||||
│ └── rooms/
|
||||
│ └── GameRoom.ts # 游戏房间
|
||||
└── client/ # 客户端示例
|
||||
└── index.ts
|
||||
\`\`\`
|
||||
|
||||
## 快速开始
|
||||
|
||||
\`\`\`bash
|
||||
# 启动服务器
|
||||
npm run dev
|
||||
|
||||
# 服务器将在 ws://localhost:3000 启动
|
||||
\`\`\`
|
||||
|
||||
## 客户端连接
|
||||
|
||||
\`\`\`typescript
|
||||
import { connect } from '@esengine/rpc/client'
|
||||
|
||||
const client = await connect('ws://localhost:3000')
|
||||
|
||||
// 加入房间
|
||||
const { roomId } = await client.call('JoinRoom', {
|
||||
roomType: 'game',
|
||||
playerName: 'Alice',
|
||||
})
|
||||
|
||||
// 监听同步
|
||||
client.onMessage('Sync', (state) => {
|
||||
console.log(state.players)
|
||||
})
|
||||
|
||||
// 发送消息
|
||||
client.send('RoomMessage', { type: 'Move', payload: { x: 100, y: 200 } })
|
||||
\`\`\`
|
||||
`
|
||||
writeFile(projectPath, 'README.md', readme)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
printLogo()
|
||||
|
||||
const program = new Command()
|
||||
|
||||
program
|
||||
.name('create-esengine-server')
|
||||
.description('Create a new ESEngine game server project')
|
||||
.version(VERSION)
|
||||
.argument('[project-name]', 'Name of the project')
|
||||
.action(async (projectName?: string) => {
|
||||
if (!projectName) {
|
||||
const response = await prompts({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'Project name:',
|
||||
initial: 'my-game-server',
|
||||
}, {
|
||||
onCancel: () => {
|
||||
console.log(chalk.yellow('\n Cancelled.'))
|
||||
process.exit(0)
|
||||
},
|
||||
})
|
||||
projectName = response.name
|
||||
}
|
||||
|
||||
if (!projectName) {
|
||||
console.log(chalk.red(' Project name is required.'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const projectPath = path.resolve(process.cwd(), projectName)
|
||||
|
||||
if (fs.existsSync(projectPath)) {
|
||||
const files = fs.readdirSync(projectPath)
|
||||
if (files.length > 0) {
|
||||
const response = await prompts({
|
||||
type: 'confirm',
|
||||
name: 'overwrite',
|
||||
message: `Directory "${projectName}" is not empty. Continue?`,
|
||||
initial: false,
|
||||
})
|
||||
if (!response.overwrite) {
|
||||
console.log(chalk.yellow('\n Cancelled.'))
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fs.mkdirSync(projectPath, { recursive: true })
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log(chalk.bold(` Creating project in ${chalk.cyan(projectPath)}...`))
|
||||
console.log()
|
||||
|
||||
generateProject(projectPath, projectName)
|
||||
console.log(chalk.green(' ✓ Created project files'))
|
||||
|
||||
const pm = detectPackageManager()
|
||||
const installCmd = getInstallCommand(pm)
|
||||
|
||||
console.log(chalk.gray(` Running ${installCmd}...`))
|
||||
console.log()
|
||||
|
||||
try {
|
||||
execSync(installCmd, { cwd: projectPath, stdio: 'inherit' })
|
||||
console.log()
|
||||
console.log(chalk.green(' ✓ Dependencies installed'))
|
||||
} catch {
|
||||
console.log(chalk.yellow(`\n ⚠ Failed to install. Run "${installCmd}" manually.`))
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log(chalk.bold(' Done! Next steps:'))
|
||||
console.log()
|
||||
console.log(chalk.cyan(` cd ${projectName}`))
|
||||
console.log(chalk.cyan(` ${pm} run dev`))
|
||||
console.log()
|
||||
console.log(chalk.gray(' Project structure:'))
|
||||
console.log(chalk.gray(' src/'))
|
||||
console.log(chalk.gray(' ├── shared/ # Shared protocol types'))
|
||||
console.log(chalk.gray(' │ └── protocol.ts'))
|
||||
console.log(chalk.gray(' ├── server/ # Server code'))
|
||||
console.log(chalk.gray(' │ ├── main.ts'))
|
||||
console.log(chalk.gray(' │ └── rooms/'))
|
||||
console.log(chalk.gray(' │ └── GameRoom.ts'))
|
||||
console.log(chalk.gray(' └── client/ # Client example'))
|
||||
console.log(chalk.gray(' └── index.ts'))
|
||||
console.log()
|
||||
})
|
||||
|
||||
program.parse()
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
9
packages/tools/create-esengine-server/tsconfig.json
Normal file
9
packages/tools/create-esengine-server/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "templates"]
|
||||
}
|
||||
13
packages/tools/create-esengine-server/tsup.config.ts
Normal file
13
packages/tools/create-esengine-server/tsup.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: false,
|
||||
clean: true,
|
||||
sourcemap: false,
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
external: ['chalk', 'commander', 'prompts'],
|
||||
})
|
||||
Reference in New Issue
Block a user