Compare commits
13 Commits
@esengine/
...
@esengine/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f84c2f870 | ||
|
|
e9ea52d9b3 | ||
|
|
0662b07445 | ||
|
|
838cda91aa | ||
|
|
a000cc07d7 | ||
|
|
1316d7de49 | ||
|
|
9c41181875 | ||
|
|
9f3f9a547a | ||
|
|
18df9d1cda | ||
|
|
9a4b3388e0 | ||
|
|
66d5dc27f7 | ||
|
|
8a3e54cb45 | ||
|
|
b6f1235239 |
2
.github/workflows/release-changesets.yml
vendored
2
.github/workflows/release-changesets.yml
vendored
@@ -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
|
||||
|
||||
@@ -20,6 +20,7 @@ ESEngine provides a rich set of modules that can be imported as needed.
|
||||
| [Timer](/en/modules/timer/) | `@esengine/timer` | Timer and cooldown system |
|
||||
| [Spatial](/en/modules/spatial/) | `@esengine/spatial` | Spatial queries, AOI management |
|
||||
| [Pathfinding](/en/modules/pathfinding/) | `@esengine/pathfinding` | A* pathfinding, NavMesh navigation |
|
||||
| [World Streaming](/en/modules/world-streaming/) | `@esengine/world-streaming` | Chunk-based world streaming for open worlds |
|
||||
|
||||
### Tools
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
251
docs/src/content/docs/en/modules/rpc/client.md
Normal file
251
docs/src/content/docs/en/modules/rpc/client.md
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
title: "RPC Client API"
|
||||
description: "RpcClient for connecting to RPC servers"
|
||||
---
|
||||
|
||||
The `RpcClient` class provides a type-safe WebSocket client for RPC communication.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { RpcClient } from '@esengine/rpc/client';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
const client = new RpcClient(gameProtocol, 'ws://localhost:3000', {
|
||||
onConnect: () => console.log('Connected'),
|
||||
onDisconnect: (reason) => console.log('Disconnected:', reason),
|
||||
onError: (error) => console.error('Error:', error),
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
```
|
||||
|
||||
## Constructor Options
|
||||
|
||||
```typescript
|
||||
interface RpcClientOptions {
|
||||
// Codec for serialization (default: json())
|
||||
codec?: Codec;
|
||||
|
||||
// API call timeout in ms (default: 30000)
|
||||
timeout?: number;
|
||||
|
||||
// Auto reconnect on disconnect (default: true)
|
||||
autoReconnect?: boolean;
|
||||
|
||||
// Reconnect interval in ms (default: 3000)
|
||||
reconnectInterval?: number;
|
||||
|
||||
// Custom WebSocket factory (for WeChat Mini Games, etc.)
|
||||
webSocketFactory?: (url: string) => WebSocketAdapter;
|
||||
|
||||
// Callbacks
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: (reason?: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## Connection
|
||||
|
||||
### Connect
|
||||
|
||||
```typescript
|
||||
// Connect returns a promise
|
||||
await client.connect();
|
||||
|
||||
// Or chain
|
||||
client.connect().then(() => {
|
||||
console.log('Ready');
|
||||
});
|
||||
```
|
||||
|
||||
### Check Status
|
||||
|
||||
```typescript
|
||||
// Connection status: 'connecting' | 'open' | 'closing' | 'closed'
|
||||
console.log(client.status);
|
||||
|
||||
// Convenience boolean
|
||||
if (client.isConnected) {
|
||||
// Safe to call APIs
|
||||
}
|
||||
```
|
||||
|
||||
### Disconnect
|
||||
|
||||
```typescript
|
||||
// Manually disconnect (disables auto-reconnect)
|
||||
client.disconnect();
|
||||
```
|
||||
|
||||
## Calling APIs
|
||||
|
||||
APIs use request-response pattern with full type safety:
|
||||
|
||||
```typescript
|
||||
// Define protocol
|
||||
const protocol = rpc.define({
|
||||
api: {
|
||||
login: rpc.api<{ username: string }, { userId: string; token: string }>(),
|
||||
getProfile: rpc.api<{ userId: string }, { name: string; level: number }>(),
|
||||
},
|
||||
msg: {}
|
||||
});
|
||||
|
||||
// Call with type inference
|
||||
const { userId, token } = await client.call('login', { username: 'player1' });
|
||||
const profile = await client.call('getProfile', { userId });
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
import { RpcError, ErrorCode } from '@esengine/rpc/client';
|
||||
|
||||
try {
|
||||
await client.call('login', { username: 'player1' });
|
||||
} catch (error) {
|
||||
if (error instanceof RpcError) {
|
||||
switch (error.code) {
|
||||
case ErrorCode.TIMEOUT:
|
||||
console.log('Request timed out');
|
||||
break;
|
||||
case ErrorCode.CONNECTION_CLOSED:
|
||||
console.log('Not connected');
|
||||
break;
|
||||
case ErrorCode.NOT_FOUND:
|
||||
console.log('API not found');
|
||||
break;
|
||||
default:
|
||||
console.log('Server error:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sending Messages
|
||||
|
||||
Messages are fire-and-forget (no response):
|
||||
|
||||
```typescript
|
||||
// Send message to server
|
||||
client.send('playerMove', { x: 100, y: 200 });
|
||||
client.send('chat', { text: 'Hello!' });
|
||||
```
|
||||
|
||||
## Receiving Messages
|
||||
|
||||
Listen for server-pushed messages:
|
||||
|
||||
```typescript
|
||||
// Subscribe to message
|
||||
client.on('newMessage', (data) => {
|
||||
console.log(`${data.from}: ${data.text}`);
|
||||
});
|
||||
|
||||
client.on('playerJoined', (data) => {
|
||||
console.log(`${data.name} joined the game`);
|
||||
});
|
||||
|
||||
// Unsubscribe specific handler
|
||||
const handler = (data) => console.log(data);
|
||||
client.on('event', handler);
|
||||
client.off('event', handler);
|
||||
|
||||
// Unsubscribe all handlers for a message
|
||||
client.off('event');
|
||||
|
||||
// One-time listener
|
||||
client.once('gameStart', (data) => {
|
||||
console.log('Game started!');
|
||||
});
|
||||
```
|
||||
|
||||
## Custom WebSocket (Platform Adapters)
|
||||
|
||||
For platforms like WeChat Mini Games:
|
||||
|
||||
```typescript
|
||||
// WeChat Mini Games adapter
|
||||
const wxWebSocketFactory = (url: string) => {
|
||||
const ws = wx.connectSocket({ url });
|
||||
|
||||
return {
|
||||
get readyState() { return ws.readyState; },
|
||||
send: (data) => ws.send({ data }),
|
||||
close: (code, reason) => ws.close({ code, reason }),
|
||||
set onopen(fn) { ws.onOpen(fn); },
|
||||
set onclose(fn) { ws.onClose((e) => fn({ code: e.code, reason: e.reason })); },
|
||||
set onerror(fn) { ws.onError(fn); },
|
||||
set onmessage(fn) { ws.onMessage((e) => fn({ data: e.data })); },
|
||||
};
|
||||
};
|
||||
|
||||
const client = new RpcClient(protocol, 'wss://game.example.com', {
|
||||
webSocketFactory: wxWebSocketFactory,
|
||||
});
|
||||
```
|
||||
|
||||
## Convenience Function
|
||||
|
||||
```typescript
|
||||
import { connect } from '@esengine/rpc/client';
|
||||
|
||||
// Connect and return client in one call
|
||||
const client = await connect(protocol, 'ws://localhost:3000', {
|
||||
onConnect: () => console.log('Connected'),
|
||||
});
|
||||
|
||||
const result = await client.call('join', { name: 'Alice' });
|
||||
```
|
||||
|
||||
## Full Example
|
||||
|
||||
```typescript
|
||||
import { RpcClient } from '@esengine/rpc/client';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
class GameClient {
|
||||
private client: RpcClient<typeof gameProtocol>;
|
||||
private userId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.client = new RpcClient(gameProtocol, 'ws://localhost:3000', {
|
||||
onConnect: () => this.onConnected(),
|
||||
onDisconnect: () => this.onDisconnected(),
|
||||
onError: (e) => console.error('RPC Error:', e),
|
||||
});
|
||||
|
||||
// Setup message handlers
|
||||
this.client.on('gameState', (state) => this.updateState(state));
|
||||
this.client.on('playerJoined', (p) => this.addPlayer(p));
|
||||
this.client.on('playerLeft', (p) => this.removePlayer(p));
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await this.client.connect();
|
||||
}
|
||||
|
||||
private async onConnected() {
|
||||
const { userId, token } = await this.client.call('login', {
|
||||
username: localStorage.getItem('username') || 'Guest',
|
||||
});
|
||||
this.userId = userId;
|
||||
console.log('Logged in as', userId);
|
||||
}
|
||||
|
||||
private onDisconnected() {
|
||||
console.log('Disconnected, will auto-reconnect...');
|
||||
}
|
||||
|
||||
async move(x: number, y: number) {
|
||||
if (!this.client.isConnected) return;
|
||||
this.client.send('move', { x, y });
|
||||
}
|
||||
|
||||
async chat(text: string) {
|
||||
await this.client.call('sendChat', { text });
|
||||
}
|
||||
}
|
||||
```
|
||||
160
docs/src/content/docs/en/modules/rpc/codec.md
Normal file
160
docs/src/content/docs/en/modules/rpc/codec.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: "RPC Codecs"
|
||||
description: "Serialization codecs for RPC communication"
|
||||
---
|
||||
|
||||
Codecs handle serialization and deserialization of RPC messages. Two built-in codecs are available.
|
||||
|
||||
## Built-in Codecs
|
||||
|
||||
### JSON Codec (Default)
|
||||
|
||||
Human-readable, widely compatible:
|
||||
|
||||
```typescript
|
||||
import { json } from '@esengine/rpc/codec';
|
||||
|
||||
const client = new RpcClient(protocol, url, {
|
||||
codec: json(),
|
||||
});
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Human-readable (easy debugging)
|
||||
- No additional dependencies
|
||||
- Universal browser support
|
||||
|
||||
**Cons:**
|
||||
- Larger message size
|
||||
- Slower serialization
|
||||
|
||||
### MessagePack Codec
|
||||
|
||||
Binary format, more efficient:
|
||||
|
||||
```typescript
|
||||
import { msgpack } from '@esengine/rpc/codec';
|
||||
|
||||
const client = new RpcClient(protocol, url, {
|
||||
codec: msgpack(),
|
||||
});
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Smaller message size (~30-50% smaller)
|
||||
- Faster serialization
|
||||
- Supports binary data natively
|
||||
|
||||
**Cons:**
|
||||
- Not human-readable
|
||||
- Requires msgpack library
|
||||
|
||||
## Codec Interface
|
||||
|
||||
```typescript
|
||||
interface Codec {
|
||||
/**
|
||||
* Encode packet to wire format
|
||||
*/
|
||||
encode(packet: unknown): string | Uint8Array;
|
||||
|
||||
/**
|
||||
* Decode wire format to packet
|
||||
*/
|
||||
decode(data: string | Uint8Array): unknown;
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Codec
|
||||
|
||||
Create your own codec for special needs:
|
||||
|
||||
```typescript
|
||||
import type { Codec } from '@esengine/rpc/codec';
|
||||
|
||||
// Example: Compressed JSON codec
|
||||
const compressedJson: () => Codec = () => ({
|
||||
encode(packet: unknown): Uint8Array {
|
||||
const json = JSON.stringify(packet);
|
||||
return compress(new TextEncoder().encode(json));
|
||||
},
|
||||
|
||||
decode(data: string | Uint8Array): unknown {
|
||||
const bytes = typeof data === 'string'
|
||||
? new TextEncoder().encode(data)
|
||||
: data;
|
||||
const decompressed = decompress(bytes);
|
||||
return JSON.parse(new TextDecoder().decode(decompressed));
|
||||
},
|
||||
});
|
||||
|
||||
// Use custom codec
|
||||
const client = new RpcClient(protocol, url, {
|
||||
codec: compressedJson(),
|
||||
});
|
||||
```
|
||||
|
||||
## Protocol Buffers Codec
|
||||
|
||||
For production games, consider Protocol Buffers:
|
||||
|
||||
```typescript
|
||||
import type { Codec } from '@esengine/rpc/codec';
|
||||
|
||||
const protobuf = (schema: ProtobufSchema): Codec => ({
|
||||
encode(packet: unknown): Uint8Array {
|
||||
return schema.Packet.encode(packet).finish();
|
||||
},
|
||||
|
||||
decode(data: string | Uint8Array): unknown {
|
||||
const bytes = typeof data === 'string'
|
||||
? new TextEncoder().encode(data)
|
||||
: data;
|
||||
return schema.Packet.decode(bytes);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Matching Client and Server
|
||||
|
||||
Both client and server must use the same codec:
|
||||
|
||||
```typescript
|
||||
// shared/codec.ts
|
||||
import { msgpack } from '@esengine/rpc/codec';
|
||||
export const gameCodec = msgpack();
|
||||
|
||||
// client.ts
|
||||
import { gameCodec } from './shared/codec';
|
||||
const client = new RpcClient(protocol, url, { codec: gameCodec });
|
||||
|
||||
// server.ts
|
||||
import { gameCodec } from './shared/codec';
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
codec: gameCodec,
|
||||
api: { /* ... */ },
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
| Codec | Encode Speed | Decode Speed | Size |
|
||||
|-------|-------------|--------------|------|
|
||||
| JSON | Medium | Medium | Large |
|
||||
| MessagePack | Fast | Fast | Small |
|
||||
| Protobuf | Fastest | Fastest | Smallest |
|
||||
|
||||
For most games, MessagePack provides a good balance. Use Protobuf for high-performance requirements.
|
||||
|
||||
## Text Encoding Utilities
|
||||
|
||||
For custom codecs, utilities are provided:
|
||||
|
||||
```typescript
|
||||
import { textEncode, textDecode } from '@esengine/rpc/codec';
|
||||
|
||||
// Works on all platforms (browser, Node.js, WeChat)
|
||||
const bytes = textEncode('Hello'); // Uint8Array
|
||||
const text = textDecode(bytes); // 'Hello'
|
||||
```
|
||||
350
docs/src/content/docs/en/modules/rpc/server.md
Normal file
350
docs/src/content/docs/en/modules/rpc/server.md
Normal file
@@ -0,0 +1,350 @@
|
||||
---
|
||||
title: "RPC Server API"
|
||||
description: "RpcServer for handling client connections"
|
||||
---
|
||||
|
||||
The `serve` function creates a type-safe RPC server that handles client connections and API calls.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { serve } from '@esengine/rpc/server';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
const server = serve(gameProtocol, {
|
||||
port: 3000,
|
||||
api: {
|
||||
login: async (input, conn) => {
|
||||
console.log(`${input.username} connected from ${conn.ip}`);
|
||||
return { userId: conn.id, token: generateToken() };
|
||||
},
|
||||
sendChat: async (input, conn) => {
|
||||
server.broadcast('newMessage', {
|
||||
from: conn.id,
|
||||
text: input.text,
|
||||
time: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
onStart: (port) => console.log(`Server started on port ${port}`),
|
||||
});
|
||||
|
||||
await server.start();
|
||||
```
|
||||
|
||||
## Server Options
|
||||
|
||||
```typescript
|
||||
interface ServeOptions<P, TConnData> {
|
||||
// Required
|
||||
port: number;
|
||||
api: ApiHandlers<P, TConnData>;
|
||||
|
||||
// Optional
|
||||
msg?: MsgHandlers<P, TConnData>;
|
||||
codec?: Codec;
|
||||
createConnData?: () => TConnData;
|
||||
|
||||
// Callbacks
|
||||
onConnect?: (conn: Connection<TConnData>) => void | Promise<void>;
|
||||
onDisconnect?: (conn: Connection<TConnData>, reason?: string) => void | Promise<void>;
|
||||
onError?: (error: Error, conn?: Connection<TConnData>) => void;
|
||||
onStart?: (port: number) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## API Handlers
|
||||
|
||||
Each API handler receives the input and connection context:
|
||||
|
||||
```typescript
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
api: {
|
||||
// Sync handler
|
||||
ping: (input, conn) => {
|
||||
return { pong: true, time: Date.now() };
|
||||
},
|
||||
|
||||
// Async handler
|
||||
getProfile: async (input, conn) => {
|
||||
const user = await database.findUser(input.userId);
|
||||
return { name: user.name, level: user.level };
|
||||
},
|
||||
|
||||
// Access connection context
|
||||
getMyInfo: (input, conn) => {
|
||||
return {
|
||||
connectionId: conn.id,
|
||||
ip: conn.ip,
|
||||
data: conn.data,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Throwing Errors
|
||||
|
||||
```typescript
|
||||
import { RpcError, ErrorCode } from '@esengine/rpc/server';
|
||||
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
api: {
|
||||
login: async (input, conn) => {
|
||||
const user = await database.findUser(input.username);
|
||||
|
||||
if (!user) {
|
||||
throw new RpcError(ErrorCode.NOT_FOUND, 'User not found');
|
||||
}
|
||||
|
||||
if (!await verifyPassword(input.password, user.hash)) {
|
||||
throw new RpcError('AUTH_FAILED', 'Invalid password');
|
||||
}
|
||||
|
||||
return { userId: user.id, token: generateToken() };
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Message Handlers
|
||||
|
||||
Handle messages sent by clients:
|
||||
|
||||
```typescript
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
api: { /* ... */ },
|
||||
msg: {
|
||||
playerMove: (data, conn) => {
|
||||
// Update player position
|
||||
const player = players.get(conn.id);
|
||||
if (player) {
|
||||
player.x = data.x;
|
||||
player.y = data.y;
|
||||
}
|
||||
|
||||
// Broadcast to others
|
||||
server.broadcast('playerMoved', {
|
||||
playerId: conn.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
}, { exclude: conn });
|
||||
},
|
||||
|
||||
chat: async (data, conn) => {
|
||||
// Async handlers work too
|
||||
await logChat(conn.id, data.text);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Connection Context
|
||||
|
||||
The `Connection` object provides access to client info:
|
||||
|
||||
```typescript
|
||||
interface Connection<TData> {
|
||||
// Unique connection ID
|
||||
readonly id: string;
|
||||
|
||||
// Client IP address
|
||||
readonly ip: string;
|
||||
|
||||
// Connection status
|
||||
readonly isOpen: boolean;
|
||||
|
||||
// Custom data attached to this connection
|
||||
data: TData;
|
||||
|
||||
// Close the connection
|
||||
close(reason?: string): void;
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Connection Data
|
||||
|
||||
Store per-connection state:
|
||||
|
||||
```typescript
|
||||
interface PlayerData {
|
||||
playerId: string;
|
||||
username: string;
|
||||
room: string | null;
|
||||
}
|
||||
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
createConnData: () => ({
|
||||
playerId: '',
|
||||
username: '',
|
||||
room: null,
|
||||
} as PlayerData),
|
||||
api: {
|
||||
login: async (input, conn) => {
|
||||
// Store data on connection
|
||||
conn.data.playerId = generateId();
|
||||
conn.data.username = input.username;
|
||||
return { playerId: conn.data.playerId };
|
||||
},
|
||||
joinRoom: async (input, conn) => {
|
||||
conn.data.room = input.roomId;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
onDisconnect: (conn) => {
|
||||
console.log(`${conn.data.username} left room ${conn.data.room}`);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Sending Messages
|
||||
|
||||
### To Single Connection
|
||||
|
||||
```typescript
|
||||
server.send(conn, 'notification', { text: 'Hello!' });
|
||||
```
|
||||
|
||||
### Broadcast to All
|
||||
|
||||
```typescript
|
||||
// To everyone
|
||||
server.broadcast('announcement', { text: 'Server restart in 5 minutes' });
|
||||
|
||||
// Exclude sender
|
||||
server.broadcast('playerMoved', { id: conn.id, x, y }, { exclude: conn });
|
||||
|
||||
// Exclude multiple
|
||||
server.broadcast('gameEvent', data, { exclude: [conn1, conn2] });
|
||||
```
|
||||
|
||||
### To Specific Group
|
||||
|
||||
```typescript
|
||||
// Custom broadcasting
|
||||
function broadcastToRoom(roomId: string, name: string, data: any) {
|
||||
for (const conn of server.connections) {
|
||||
if (conn.data.room === roomId) {
|
||||
server.send(conn, name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcastToRoom('room1', 'roomMessage', { text: 'Hello room!' });
|
||||
```
|
||||
|
||||
## Server Lifecycle
|
||||
|
||||
```typescript
|
||||
const server = serve(protocol, { /* ... */ });
|
||||
|
||||
// Start
|
||||
await server.start();
|
||||
console.log('Server running');
|
||||
|
||||
// Access connections
|
||||
console.log(`${server.connections.length} clients connected`);
|
||||
|
||||
// Stop (closes all connections)
|
||||
await server.stop();
|
||||
console.log('Server stopped');
|
||||
```
|
||||
|
||||
## Full Example
|
||||
|
||||
```typescript
|
||||
import { serve, RpcError } from '@esengine/rpc/server';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
interface PlayerData {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const players = new Map<string, PlayerData>();
|
||||
|
||||
const server = serve(gameProtocol, {
|
||||
port: 3000,
|
||||
createConnData: () => ({ id: '', name: '', x: 0, y: 0 }),
|
||||
|
||||
api: {
|
||||
join: async (input, conn) => {
|
||||
const player: PlayerData = {
|
||||
id: conn.id,
|
||||
name: input.name,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
players.set(conn.id, player);
|
||||
conn.data = player;
|
||||
|
||||
// Notify others
|
||||
server.broadcast('playerJoined', {
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
}, { exclude: conn });
|
||||
|
||||
// Send current state to new player
|
||||
return {
|
||||
playerId: player.id,
|
||||
players: Array.from(players.values()),
|
||||
};
|
||||
},
|
||||
|
||||
chat: async (input, conn) => {
|
||||
server.broadcast('chatMessage', {
|
||||
from: conn.data.name,
|
||||
text: input.text,
|
||||
time: Date.now(),
|
||||
});
|
||||
return { sent: true };
|
||||
},
|
||||
},
|
||||
|
||||
msg: {
|
||||
move: (data, conn) => {
|
||||
const player = players.get(conn.id);
|
||||
if (player) {
|
||||
player.x = data.x;
|
||||
player.y = data.y;
|
||||
|
||||
server.broadcast('playerMoved', {
|
||||
id: conn.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
}, { exclude: conn });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
onConnect: (conn) => {
|
||||
console.log(`Client connected: ${conn.id} from ${conn.ip}`);
|
||||
},
|
||||
|
||||
onDisconnect: (conn) => {
|
||||
const player = players.get(conn.id);
|
||||
if (player) {
|
||||
players.delete(conn.id);
|
||||
server.broadcast('playerLeft', { id: conn.id });
|
||||
console.log(`${player.name} disconnected`);
|
||||
}
|
||||
},
|
||||
|
||||
onError: (error, conn) => {
|
||||
console.error(`Error from ${conn?.id}:`, error);
|
||||
},
|
||||
|
||||
onStart: (port) => {
|
||||
console.log(`Game server running on ws://localhost:${port}`);
|
||||
},
|
||||
});
|
||||
|
||||
server.start();
|
||||
```
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
title: "Chunk Manager API"
|
||||
description: "ChunkManager handles chunk lifecycle, loading queue, and spatial queries"
|
||||
---
|
||||
|
||||
The `ChunkManager` is the core service responsible for managing chunk lifecycle, including loading, unloading, and spatial queries.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { ChunkManager } from '@esengine/world-streaming';
|
||||
|
||||
// Create manager with 512-unit chunks
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
|
||||
// Set data provider for loading chunks
|
||||
chunkManager.setDataProvider(myProvider);
|
||||
|
||||
// Set event callbacks
|
||||
chunkManager.setEvents({
|
||||
onChunkLoaded: (coord, entities) => {
|
||||
console.log(`Chunk (${coord.x}, ${coord.y}) loaded with ${entities.length} entities`);
|
||||
},
|
||||
onChunkUnloaded: (coord) => {
|
||||
console.log(`Chunk (${coord.x}, ${coord.y}) unloaded`);
|
||||
},
|
||||
onChunkLoadFailed: (coord, error) => {
|
||||
console.error(`Failed to load chunk (${coord.x}, ${coord.y}):`, error);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Loading and Unloading
|
||||
|
||||
### Request Loading
|
||||
|
||||
```typescript
|
||||
import { EChunkPriority } from '@esengine/world-streaming';
|
||||
|
||||
// Request with priority
|
||||
chunkManager.requestLoad({ x: 0, y: 0 }, EChunkPriority.Immediate);
|
||||
chunkManager.requestLoad({ x: 1, y: 0 }, EChunkPriority.High);
|
||||
chunkManager.requestLoad({ x: 2, y: 0 }, EChunkPriority.Normal);
|
||||
chunkManager.requestLoad({ x: 3, y: 0 }, EChunkPriority.Low);
|
||||
chunkManager.requestLoad({ x: 4, y: 0 }, EChunkPriority.Prefetch);
|
||||
```
|
||||
|
||||
### Priority Levels
|
||||
|
||||
| Priority | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `Immediate` | 0 | Current chunk (player standing on) |
|
||||
| `High` | 1 | Adjacent chunks |
|
||||
| `Normal` | 2 | Nearby chunks |
|
||||
| `Low` | 3 | Distant visible chunks |
|
||||
| `Prefetch` | 4 | Movement direction prefetch |
|
||||
|
||||
### Request Unloading
|
||||
|
||||
```typescript
|
||||
// Request unload with 3 second delay
|
||||
chunkManager.requestUnload({ x: 5, y: 5 }, 3000);
|
||||
|
||||
// Cancel pending unload (player moved back)
|
||||
chunkManager.cancelUnload({ x: 5, y: 5 });
|
||||
```
|
||||
|
||||
### Process Queues
|
||||
|
||||
```typescript
|
||||
// In your update loop or system
|
||||
await chunkManager.processLoads(2); // Load up to 2 chunks per frame
|
||||
chunkManager.processUnloads(1); // Unload up to 1 chunk per frame
|
||||
```
|
||||
|
||||
## Spatial Queries
|
||||
|
||||
### Coordinate Conversion
|
||||
|
||||
```typescript
|
||||
// World position to chunk coordinates
|
||||
const coord = chunkManager.worldToChunk(1500, 2300);
|
||||
// Result: { x: 2, y: 4 } for 512-unit chunks
|
||||
|
||||
// Get chunk bounds in world space
|
||||
const bounds = chunkManager.getChunkBounds({ x: 2, y: 4 });
|
||||
// Result: { minX: 1024, minY: 2048, maxX: 1536, maxY: 2560 }
|
||||
```
|
||||
|
||||
### Chunk Queries
|
||||
|
||||
```typescript
|
||||
// Check if chunk is loaded
|
||||
if (chunkManager.isChunkLoaded({ x: 0, y: 0 })) {
|
||||
const chunk = chunkManager.getChunk({ x: 0, y: 0 });
|
||||
console.log('Entities:', chunk.entities.length);
|
||||
}
|
||||
|
||||
// Get missing chunks in radius
|
||||
const missing = chunkManager.getMissingChunks({ x: 0, y: 0 }, 2);
|
||||
for (const coord of missing) {
|
||||
chunkManager.requestLoad(coord);
|
||||
}
|
||||
|
||||
// Get chunks outside radius (for unloading)
|
||||
const outside = chunkManager.getChunksOutsideRadius({ x: 0, y: 0 }, 4);
|
||||
for (const coord of outside) {
|
||||
chunkManager.requestUnload(coord, 3000);
|
||||
}
|
||||
|
||||
// Iterate all loaded chunks
|
||||
chunkManager.forEachChunk((info, coord) => {
|
||||
console.log(`Chunk (${coord.x}, ${coord.y}): ${info.state}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Statistics
|
||||
|
||||
```typescript
|
||||
console.log('Loaded chunks:', chunkManager.loadedChunkCount);
|
||||
console.log('Pending loads:', chunkManager.pendingLoadCount);
|
||||
console.log('Pending unloads:', chunkManager.pendingUnloadCount);
|
||||
console.log('Chunk size:', chunkManager.chunkSize);
|
||||
```
|
||||
|
||||
## Chunk States
|
||||
|
||||
```typescript
|
||||
import { EChunkState } from '@esengine/world-streaming';
|
||||
|
||||
// Chunk lifecycle states
|
||||
EChunkState.Unloaded // Not in memory
|
||||
EChunkState.Loading // Being loaded
|
||||
EChunkState.Loaded // Ready for use
|
||||
EChunkState.Unloading // Being removed
|
||||
EChunkState.Failed // Load failed
|
||||
```
|
||||
|
||||
## Data Provider Interface
|
||||
|
||||
```typescript
|
||||
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||
|
||||
class MyChunkProvider implements IChunkDataProvider {
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
// Load from database, file, or procedural generation
|
||||
const data = await fetchChunkFromServer(coord);
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
// Save modified chunks
|
||||
await saveChunkToServer(data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
```typescript
|
||||
// Unload all chunks
|
||||
chunkManager.clear();
|
||||
|
||||
// Full disposal (implements IService)
|
||||
chunkManager.dispose();
|
||||
```
|
||||
330
docs/src/content/docs/en/modules/world-streaming/examples.md
Normal file
330
docs/src/content/docs/en/modules/world-streaming/examples.md
Normal file
@@ -0,0 +1,330 @@
|
||||
---
|
||||
title: "Examples"
|
||||
description: "Practical examples of world streaming"
|
||||
---
|
||||
|
||||
## Infinite Procedural World
|
||||
|
||||
An infinite world with procedural resource generation.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChunkManager,
|
||||
ChunkStreamingSystem,
|
||||
ChunkLoaderComponent,
|
||||
StreamingAnchorComponent
|
||||
} from '@esengine/world-streaming';
|
||||
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||
|
||||
// Procedural world generator
|
||||
class WorldGenerator implements IChunkDataProvider {
|
||||
private seed: number;
|
||||
private nextEntityId = 1;
|
||||
|
||||
constructor(seed: number = 12345) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const rng = this.createChunkRNG(coord);
|
||||
const entities = [];
|
||||
|
||||
// Generate 5-15 resources per chunk
|
||||
const resourceCount = 5 + Math.floor(rng() * 10);
|
||||
|
||||
for (let i = 0; i < resourceCount; i++) {
|
||||
const type = this.randomResourceType(rng);
|
||||
|
||||
entities.push({
|
||||
name: `Resource_${this.nextEntityId++}`,
|
||||
localPosition: {
|
||||
x: rng() * 512,
|
||||
y: rng() * 512
|
||||
},
|
||||
components: {
|
||||
ResourceNode: {
|
||||
type,
|
||||
amount: this.getResourceAmount(type, rng),
|
||||
regenRate: this.getRegenRate(type)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { coord, entities, version: 1 };
|
||||
}
|
||||
|
||||
async saveChunkData(_data: IChunkData): Promise<void> {
|
||||
// Procedural - no persistence needed
|
||||
}
|
||||
|
||||
private createChunkRNG(coord: IChunkCoord) {
|
||||
let seed = this.seed ^ (coord.x * 73856093) ^ (coord.y * 19349663);
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
private randomResourceType(rng: () => number) {
|
||||
const types = ['energyWell', 'oreVein', 'crystalDeposit'];
|
||||
const weights = [0.5, 0.35, 0.15];
|
||||
|
||||
let random = rng();
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
random -= weights[i];
|
||||
if (random <= 0) return types[i];
|
||||
}
|
||||
return types[0];
|
||||
}
|
||||
|
||||
private getResourceAmount(type: string, rng: () => number) {
|
||||
switch (type) {
|
||||
case 'energyWell': return 300 + Math.floor(rng() * 200);
|
||||
case 'oreVein': return 500 + Math.floor(rng() * 300);
|
||||
case 'crystalDeposit': return 100 + Math.floor(rng() * 100);
|
||||
default: return 100;
|
||||
}
|
||||
}
|
||||
|
||||
private getRegenRate(type: string) {
|
||||
switch (type) {
|
||||
case 'energyWell': return 2;
|
||||
case 'oreVein': return 1;
|
||||
case 'crystalDeposit': return 0.2;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setup
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
chunkManager.setDataProvider(new WorldGenerator(12345));
|
||||
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
streamingSystem.setChunkManager(chunkManager);
|
||||
scene.addSystem(streamingSystem);
|
||||
```
|
||||
|
||||
## MMO Server Chunks
|
||||
|
||||
Server-side chunk management for MMO with database persistence.
|
||||
|
||||
```typescript
|
||||
class ServerChunkProvider implements IChunkDataProvider {
|
||||
private db: Database;
|
||||
private cache = new Map<string, IChunkData>();
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const key = `${coord.x},${coord.y}`;
|
||||
|
||||
// Check cache
|
||||
if (this.cache.has(key)) {
|
||||
return this.cache.get(key)!;
|
||||
}
|
||||
|
||||
// Load from database
|
||||
const row = await this.db.query(
|
||||
'SELECT data FROM chunks WHERE x = ? AND y = ?',
|
||||
[coord.x, coord.y]
|
||||
);
|
||||
|
||||
if (row) {
|
||||
const data = JSON.parse(row.data);
|
||||
this.cache.set(key, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Generate new chunk
|
||||
const data = this.generateChunk(coord);
|
||||
await this.saveChunkData(data);
|
||||
this.cache.set(key, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
const key = `${data.coord.x},${data.coord.y}`;
|
||||
this.cache.set(key, data);
|
||||
|
||||
await this.db.query(
|
||||
`INSERT INTO chunks (x, y, data) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE data = VALUES(data)`,
|
||||
[data.coord.x, data.coord.y, JSON.stringify(data)]
|
||||
);
|
||||
}
|
||||
|
||||
private generateChunk(coord: IChunkCoord): IChunkData {
|
||||
// Procedural generation for new chunks
|
||||
return { coord, entities: [], version: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// Per-player chunk loading on server
|
||||
class PlayerChunkManager {
|
||||
private chunkManager: ChunkManager;
|
||||
private playerChunks = new Map<string, Set<string>>();
|
||||
|
||||
async updatePlayerPosition(playerId: string, x: number, y: number) {
|
||||
const centerCoord = this.chunkManager.worldToChunk(x, y);
|
||||
const loadRadius = 2;
|
||||
|
||||
const newChunks = new Set<string>();
|
||||
|
||||
// Load chunks around player
|
||||
for (let dx = -loadRadius; dx <= loadRadius; dx++) {
|
||||
for (let dy = -loadRadius; dy <= loadRadius; dy++) {
|
||||
const coord = { x: centerCoord.x + dx, y: centerCoord.y + dy };
|
||||
const key = `${coord.x},${coord.y}`;
|
||||
newChunks.add(key);
|
||||
|
||||
if (!this.chunkManager.isChunkLoaded(coord)) {
|
||||
await this.chunkManager.requestLoad(coord);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track player's loaded chunks
|
||||
this.playerChunks.set(playerId, newChunks);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tile-Based World
|
||||
|
||||
Tilemap integration with chunk streaming.
|
||||
|
||||
```typescript
|
||||
import { TilemapComponent } from '@esengine/tilemap';
|
||||
|
||||
class TilemapChunkProvider implements IChunkDataProvider {
|
||||
private tilemapData: number[][]; // Full tilemap
|
||||
private tileSize = 32;
|
||||
private chunkTiles = 16; // 16x16 tiles per chunk
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const startTileX = coord.x * this.chunkTiles;
|
||||
const startTileY = coord.y * this.chunkTiles;
|
||||
|
||||
// Extract tiles for this chunk
|
||||
const tiles: number[][] = [];
|
||||
for (let y = 0; y < this.chunkTiles; y++) {
|
||||
const row: number[] = [];
|
||||
for (let x = 0; x < this.chunkTiles; x++) {
|
||||
const tileX = startTileX + x;
|
||||
const tileY = startTileY + y;
|
||||
row.push(this.getTile(tileX, tileY));
|
||||
}
|
||||
tiles.push(row);
|
||||
}
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities: [{
|
||||
name: `TileChunk_${coord.x}_${coord.y}`,
|
||||
localPosition: { x: 0, y: 0 },
|
||||
components: {
|
||||
TilemapChunk: { tiles }
|
||||
}
|
||||
}],
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
private getTile(x: number, y: number): number {
|
||||
if (x < 0 || y < 0 || y >= this.tilemapData.length) {
|
||||
return 0; // Out of bounds = empty
|
||||
}
|
||||
return this.tilemapData[y]?.[x] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom serializer for tilemap
|
||||
class TilemapSerializer extends ChunkSerializer {
|
||||
protected deserializeComponents(entity: Entity, components: Record<string, unknown>): void {
|
||||
if (components.TilemapChunk) {
|
||||
const data = components.TilemapChunk as { tiles: number[][] };
|
||||
const tilemap = entity.addComponent(new TilemapComponent());
|
||||
tilemap.loadTiles(data.tiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic Loading Events
|
||||
|
||||
React to chunk loading for gameplay.
|
||||
|
||||
```typescript
|
||||
chunkManager.setEvents({
|
||||
onChunkLoaded: (coord, entities) => {
|
||||
// Enable physics
|
||||
for (const entity of entities) {
|
||||
const collider = entity.getComponent(ColliderComponent);
|
||||
collider?.enable();
|
||||
}
|
||||
|
||||
// Spawn NPCs for loaded chunks
|
||||
npcManager.spawnForChunk(coord);
|
||||
|
||||
// Update fog of war
|
||||
fogOfWar.revealChunk(coord);
|
||||
|
||||
// Notify clients (server)
|
||||
broadcast('ChunkLoaded', { coord, entityCount: entities.length });
|
||||
},
|
||||
|
||||
onChunkUnloaded: (coord) => {
|
||||
// Save NPC states
|
||||
npcManager.saveAndRemoveForChunk(coord);
|
||||
|
||||
// Update fog
|
||||
fogOfWar.hideChunk(coord);
|
||||
|
||||
// Notify clients
|
||||
broadcast('ChunkUnloaded', { coord });
|
||||
},
|
||||
|
||||
onChunkLoadFailed: (coord, error) => {
|
||||
console.error(`Failed to load chunk ${coord.x},${coord.y}:`, error);
|
||||
|
||||
// Retry after delay
|
||||
setTimeout(() => {
|
||||
chunkManager.requestLoad(coord);
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
```typescript
|
||||
// Adjust based on device performance
|
||||
function configureForDevice(loader: ChunkLoaderComponent) {
|
||||
const memory = navigator.deviceMemory ?? 4;
|
||||
const cores = navigator.hardwareConcurrency ?? 4;
|
||||
|
||||
if (memory <= 2 || cores <= 2) {
|
||||
// Low-end device
|
||||
loader.loadRadius = 1;
|
||||
loader.unloadRadius = 2;
|
||||
loader.maxLoadsPerFrame = 1;
|
||||
loader.bEnablePrefetch = false;
|
||||
} else if (memory <= 4) {
|
||||
// Mid-range
|
||||
loader.loadRadius = 2;
|
||||
loader.unloadRadius = 3;
|
||||
loader.maxLoadsPerFrame = 2;
|
||||
} else {
|
||||
// High-end
|
||||
loader.loadRadius = 3;
|
||||
loader.unloadRadius = 5;
|
||||
loader.maxLoadsPerFrame = 4;
|
||||
loader.prefetchRadius = 2;
|
||||
}
|
||||
}
|
||||
```
|
||||
158
docs/src/content/docs/en/modules/world-streaming/index.md
Normal file
158
docs/src/content/docs/en/modules/world-streaming/index.md
Normal file
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: "World Streaming"
|
||||
description: "Chunk-based world streaming for open world games"
|
||||
---
|
||||
|
||||
`@esengine/world-streaming` provides chunk-based world streaming and management for open world games. It handles dynamic loading/unloading of world chunks based on player position.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @esengine/world-streaming
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChunkManager,
|
||||
ChunkStreamingSystem,
|
||||
StreamingAnchorComponent,
|
||||
ChunkLoaderComponent
|
||||
} from '@esengine/world-streaming';
|
||||
|
||||
// Create chunk manager (512 unit chunks)
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
|
||||
// Add streaming system
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
streamingSystem.setChunkManager(chunkManager);
|
||||
scene.addSystem(streamingSystem);
|
||||
|
||||
// Create loader entity with config
|
||||
const loaderEntity = scene.createEntity('ChunkLoader');
|
||||
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
|
||||
loader.chunkSize = 512;
|
||||
loader.loadRadius = 2;
|
||||
loader.unloadRadius = 4;
|
||||
|
||||
// Create player as streaming anchor
|
||||
const playerEntity = scene.createEntity('Player');
|
||||
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
|
||||
|
||||
// Update anchor position each frame
|
||||
function update() {
|
||||
anchor.x = player.position.x;
|
||||
anchor.y = player.position.y;
|
||||
}
|
||||
```
|
||||
|
||||
### Procedural Generation
|
||||
|
||||
```typescript
|
||||
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||
|
||||
class ProceduralChunkProvider implements IChunkDataProvider {
|
||||
private seed: number;
|
||||
|
||||
constructor(seed: number) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
// Use deterministic random based on seed + coord
|
||||
const chunkSeed = this.hashCoord(coord);
|
||||
const rng = this.createRNG(chunkSeed);
|
||||
|
||||
// Generate chunk content
|
||||
const entities = this.generateEntities(coord, rng);
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities,
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
// Optional: persist modified chunks
|
||||
}
|
||||
|
||||
private hashCoord(coord: IChunkCoord): number {
|
||||
return this.seed ^ (coord.x * 73856093) ^ (coord.y * 19349663);
|
||||
}
|
||||
|
||||
private createRNG(seed: number) {
|
||||
// Simple seeded random
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
private generateEntities(coord: IChunkCoord, rng: () => number) {
|
||||
// Generate resources, trees, etc.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Use provider
|
||||
chunkManager.setDataProvider(new ProceduralChunkProvider(12345));
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Chunk Lifecycle
|
||||
|
||||
```
|
||||
Unloaded → Loading → Loaded → Unloading → Unloaded
|
||||
↓ ↓
|
||||
Failed (on error)
|
||||
```
|
||||
|
||||
### Streaming Anchor
|
||||
|
||||
`StreamingAnchorComponent` marks entities as chunk loading anchors. The system loads chunks around all anchors and unloads chunks outside the combined range.
|
||||
|
||||
```typescript
|
||||
// StreamingAnchorComponent implements IPositionable
|
||||
interface IPositionable {
|
||||
readonly position: { x: number; y: number };
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
| Property | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `chunkSize` | 512 | Chunk size in world units |
|
||||
| `loadRadius` | 2 | Chunks to load around anchor |
|
||||
| `unloadRadius` | 4 | Chunks to unload beyond this |
|
||||
| `maxLoadsPerFrame` | 2 | Max async loads per frame |
|
||||
| `unloadDelay` | 3000 | MS before unloading |
|
||||
| `bEnablePrefetch` | true | Prefetch in movement direction |
|
||||
|
||||
## Module Setup (Optional)
|
||||
|
||||
For quick setup, use the module helper:
|
||||
|
||||
```typescript
|
||||
import { worldStreamingModule } from '@esengine/world-streaming';
|
||||
|
||||
const chunkManager = worldStreamingModule.setup(
|
||||
scene,
|
||||
services,
|
||||
componentRegistry,
|
||||
{ chunkSize: 256, bEnableCulling: true }
|
||||
);
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Chunk Manager API](./chunk-manager) - Loading queue, chunk lifecycle
|
||||
- [Streaming System](./streaming-system) - Anchor-based loading
|
||||
- [Serialization](./serialization) - Custom chunk serialization
|
||||
- [Examples](./examples) - Procedural worlds, MMO chunks
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
title: "Chunk Serialization"
|
||||
description: "Custom serialization for chunk data"
|
||||
---
|
||||
|
||||
The `ChunkSerializer` handles converting between entity data and chunk storage format.
|
||||
|
||||
## Default Serializer
|
||||
|
||||
```typescript
|
||||
import { ChunkSerializer, ChunkManager } from '@esengine/world-streaming';
|
||||
|
||||
const serializer = new ChunkSerializer();
|
||||
const chunkManager = new ChunkManager(512, serializer);
|
||||
```
|
||||
|
||||
## Custom Serializer
|
||||
|
||||
Override `ChunkSerializer` for custom serialization logic:
|
||||
|
||||
```typescript
|
||||
import { ChunkSerializer } from '@esengine/world-streaming';
|
||||
import type { Entity, IScene } from '@esengine/ecs-framework';
|
||||
import type { IChunkCoord, IChunkData, IChunkBounds } from '@esengine/world-streaming';
|
||||
|
||||
class GameChunkSerializer extends ChunkSerializer {
|
||||
/**
|
||||
* Get position from entity
|
||||
* Override to use your position component
|
||||
*/
|
||||
protected getPositionable(entity: Entity) {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
if (transform) {
|
||||
return { position: { x: transform.x, y: transform.y } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set position on entity after deserialization
|
||||
*/
|
||||
protected setEntityPosition(entity: Entity, x: number, y: number): void {
|
||||
const transform = entity.addComponent(new TransformComponent());
|
||||
transform.x = x;
|
||||
transform.y = y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize components
|
||||
*/
|
||||
protected serializeComponents(entity: Entity): Record<string, unknown> {
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
const resource = entity.getComponent(ResourceComponent);
|
||||
if (resource) {
|
||||
data.ResourceComponent = {
|
||||
type: resource.type,
|
||||
amount: resource.amount,
|
||||
maxAmount: resource.maxAmount
|
||||
};
|
||||
}
|
||||
|
||||
const npc = entity.getComponent(NPCComponent);
|
||||
if (npc) {
|
||||
data.NPCComponent = {
|
||||
id: npc.id,
|
||||
state: npc.state
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize components
|
||||
*/
|
||||
protected deserializeComponents(entity: Entity, components: Record<string, unknown>): void {
|
||||
if (components.ResourceComponent) {
|
||||
const data = components.ResourceComponent as any;
|
||||
const resource = entity.addComponent(new ResourceComponent());
|
||||
resource.type = data.type;
|
||||
resource.amount = data.amount;
|
||||
resource.maxAmount = data.maxAmount;
|
||||
}
|
||||
|
||||
if (components.NPCComponent) {
|
||||
const data = components.NPCComponent as any;
|
||||
const npc = entity.addComponent(new NPCComponent());
|
||||
npc.id = data.id;
|
||||
npc.state = data.state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter which components to serialize
|
||||
*/
|
||||
protected shouldSerializeComponent(componentName: string): boolean {
|
||||
const include = ['ResourceComponent', 'NPCComponent', 'BuildingComponent'];
|
||||
return include.includes(componentName);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Chunk Data Format
|
||||
|
||||
```typescript
|
||||
interface IChunkData {
|
||||
coord: IChunkCoord; // Chunk coordinates
|
||||
entities: ISerializedEntity[]; // Entity data
|
||||
version: number; // Data version
|
||||
}
|
||||
|
||||
interface ISerializedEntity {
|
||||
name: string; // Entity name
|
||||
localPosition: { x: number; y: number }; // Position within chunk
|
||||
components: Record<string, unknown>; // Component data
|
||||
}
|
||||
|
||||
interface IChunkCoord {
|
||||
x: number; // Chunk X coordinate
|
||||
y: number; // Chunk Y coordinate
|
||||
}
|
||||
```
|
||||
|
||||
## Data Provider with Serialization
|
||||
|
||||
```typescript
|
||||
class DatabaseChunkProvider implements IChunkDataProvider {
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const key = `chunk_${coord.x}_${coord.y}`;
|
||||
const json = await database.get(key);
|
||||
|
||||
if (!json) return null;
|
||||
return JSON.parse(json) as IChunkData;
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
const key = `chunk_${data.coord.x}_${data.coord.y}`;
|
||||
await database.set(key, JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Procedural Generation with Serializer
|
||||
|
||||
```typescript
|
||||
class ProceduralProvider implements IChunkDataProvider {
|
||||
private serializer: GameChunkSerializer;
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const entities = this.generateEntities(coord);
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities,
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
private generateEntities(coord: IChunkCoord): ISerializedEntity[] {
|
||||
const entities: ISerializedEntity[] = [];
|
||||
const rng = this.createRNG(coord);
|
||||
|
||||
// Generate trees
|
||||
const treeCount = Math.floor(rng() * 10);
|
||||
for (let i = 0; i < treeCount; i++) {
|
||||
entities.push({
|
||||
name: `Tree_${coord.x}_${coord.y}_${i}`,
|
||||
localPosition: {
|
||||
x: rng() * 512,
|
||||
y: rng() * 512
|
||||
},
|
||||
components: {
|
||||
TreeComponent: { type: 'oak', health: 100 }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Generate resources
|
||||
if (rng() > 0.7) {
|
||||
entities.push({
|
||||
name: `Resource_${coord.x}_${coord.y}`,
|
||||
localPosition: { x: 256, y: 256 },
|
||||
components: {
|
||||
ResourceComponent: {
|
||||
type: 'iron',
|
||||
amount: 500,
|
||||
maxAmount: 500
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Version Migration
|
||||
|
||||
```typescript
|
||||
class VersionedSerializer extends ChunkSerializer {
|
||||
private static readonly CURRENT_VERSION = 2;
|
||||
|
||||
deserialize(data: IChunkData, scene: IScene): Entity[] {
|
||||
// Migrate old data
|
||||
if (data.version < 2) {
|
||||
data = this.migrateV1toV2(data);
|
||||
}
|
||||
|
||||
return super.deserialize(data, scene);
|
||||
}
|
||||
|
||||
private migrateV1toV2(data: IChunkData): IChunkData {
|
||||
// Convert old component format
|
||||
for (const entity of data.entities) {
|
||||
if (entity.components.OldResource) {
|
||||
entity.components.ResourceComponent = entity.components.OldResource;
|
||||
delete entity.components.OldResource;
|
||||
}
|
||||
}
|
||||
|
||||
data.version = 2;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
title: "Streaming System"
|
||||
description: "ChunkStreamingSystem manages automatic chunk loading based on anchor positions"
|
||||
---
|
||||
|
||||
The `ChunkStreamingSystem` automatically manages chunk loading and unloading based on `StreamingAnchorComponent` positions.
|
||||
|
||||
## Setup
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChunkManager,
|
||||
ChunkStreamingSystem,
|
||||
ChunkLoaderComponent,
|
||||
StreamingAnchorComponent
|
||||
} from '@esengine/world-streaming';
|
||||
|
||||
// Create and configure chunk manager
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
chunkManager.setDataProvider(myProvider);
|
||||
|
||||
// Create streaming system
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
streamingSystem.setChunkManager(chunkManager);
|
||||
scene.addSystem(streamingSystem);
|
||||
|
||||
// Create loader entity with configuration
|
||||
const loaderEntity = scene.createEntity('ChunkLoader');
|
||||
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
|
||||
loader.chunkSize = 512;
|
||||
loader.loadRadius = 2;
|
||||
loader.unloadRadius = 4;
|
||||
```
|
||||
|
||||
## Streaming Anchor
|
||||
|
||||
The `StreamingAnchorComponent` marks entities as chunk loading anchors. Chunks are loaded around all anchors.
|
||||
|
||||
```typescript
|
||||
// Create player as streaming anchor
|
||||
const playerEntity = scene.createEntity('Player');
|
||||
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
|
||||
|
||||
// Update position each frame
|
||||
function update() {
|
||||
anchor.x = player.worldX;
|
||||
anchor.y = player.worldY;
|
||||
}
|
||||
```
|
||||
|
||||
### Anchor Properties
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `x` | number | 0 | World X position |
|
||||
| `y` | number | 0 | World Y position |
|
||||
| `weight` | number | 1.0 | Load radius multiplier |
|
||||
| `bEnablePrefetch` | boolean | true | Enable prefetch for this anchor |
|
||||
|
||||
### Multiple Anchors
|
||||
|
||||
```typescript
|
||||
// Main player - full load radius
|
||||
const playerAnchor = player.addComponent(new StreamingAnchorComponent());
|
||||
playerAnchor.weight = 1.0;
|
||||
|
||||
// Camera preview - smaller radius
|
||||
const cameraAnchor = camera.addComponent(new StreamingAnchorComponent());
|
||||
cameraAnchor.weight = 0.5; // Half the load radius
|
||||
cameraAnchor.bEnablePrefetch = false;
|
||||
```
|
||||
|
||||
## Loader Configuration
|
||||
|
||||
The `ChunkLoaderComponent` configures streaming behavior.
|
||||
|
||||
```typescript
|
||||
const loader = entity.addComponent(new ChunkLoaderComponent());
|
||||
|
||||
// Chunk dimensions
|
||||
loader.chunkSize = 512; // World units per chunk
|
||||
|
||||
// Loading radius
|
||||
loader.loadRadius = 2; // Load chunks within 2 chunks of anchor
|
||||
loader.unloadRadius = 4; // Unload beyond 4 chunks
|
||||
|
||||
// Performance tuning
|
||||
loader.maxLoadsPerFrame = 2; // Max async loads per frame
|
||||
loader.maxUnloadsPerFrame = 1; // Max unloads per frame
|
||||
loader.unloadDelay = 3000; // MS before unloading
|
||||
|
||||
// Prefetch
|
||||
loader.bEnablePrefetch = true; // Enable movement-based prefetch
|
||||
loader.prefetchRadius = 1; // Extra chunks to prefetch
|
||||
```
|
||||
|
||||
### Coordinate Helpers
|
||||
|
||||
```typescript
|
||||
// Convert world position to chunk coordinates
|
||||
const coord = loader.worldToChunk(1500, 2300);
|
||||
|
||||
// Get chunk bounds
|
||||
const bounds = loader.getChunkBounds(coord);
|
||||
```
|
||||
|
||||
## Prefetch System
|
||||
|
||||
When enabled, the system prefetches chunks in the movement direction:
|
||||
|
||||
```
|
||||
Movement Direction →
|
||||
|
||||
[ ][ ][ ] [ ][P][P] P = Prefetch
|
||||
[L][L][L] → [L][L][L] L = Loaded
|
||||
[ ][ ][ ] [ ][ ][ ]
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Enable prefetch
|
||||
loader.bEnablePrefetch = true;
|
||||
loader.prefetchRadius = 2; // Prefetch 2 chunks ahead
|
||||
|
||||
// Per-anchor prefetch control
|
||||
anchor.bEnablePrefetch = true; // Enable for main player
|
||||
cameraAnchor.bEnablePrefetch = false; // Disable for camera
|
||||
```
|
||||
|
||||
## System Processing
|
||||
|
||||
The system runs each frame and:
|
||||
|
||||
1. Updates anchor velocities
|
||||
2. Requests loads for chunks in range
|
||||
3. Cancels unloads for chunks back in range
|
||||
4. Requests unloads for chunks outside range
|
||||
5. Processes load/unload queues
|
||||
|
||||
```typescript
|
||||
// Access the chunk manager from system
|
||||
const system = scene.getSystem(ChunkStreamingSystem);
|
||||
const manager = system?.chunkManager;
|
||||
|
||||
if (manager) {
|
||||
console.log('Loaded:', manager.loadedChunkCount);
|
||||
}
|
||||
```
|
||||
|
||||
## Priority-Based Loading
|
||||
|
||||
Chunks are loaded with priority based on distance:
|
||||
|
||||
| Distance | Priority | Description |
|
||||
|----------|----------|-------------|
|
||||
| 0 | Immediate | Player's current chunk |
|
||||
| 1 | High | Adjacent chunks |
|
||||
| 2-4 | Normal | Nearby chunks |
|
||||
| 5+ | Low | Distant chunks |
|
||||
| Prefetch | Prefetch | Movement direction |
|
||||
|
||||
## Events
|
||||
|
||||
```typescript
|
||||
chunkManager.setEvents({
|
||||
onChunkLoaded: (coord, entities) => {
|
||||
// Chunk ready - spawn NPCs, enable collision
|
||||
for (const entity of entities) {
|
||||
entity.getComponent(ColliderComponent)?.enable();
|
||||
}
|
||||
},
|
||||
onChunkUnloaded: (coord) => {
|
||||
// Cleanup - save state, release resources
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -28,6 +28,7 @@ ESEngine 提供了丰富的功能模块,可以按需引入到你的项目中
|
||||
|------|------|------|
|
||||
| [可视化脚本](/modules/blueprint/) | `@esengine/blueprint` | 蓝图可视化脚本系统 |
|
||||
| [程序化生成](/modules/procgen/) | `@esengine/procgen` | 噪声函数、随机工具 |
|
||||
| [世界流式加载](/modules/world-streaming/) | `@esengine/world-streaming` | 开放世界区块流式加载 |
|
||||
|
||||
### 网络模块
|
||||
|
||||
|
||||
@@ -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` 中保存重要数据
|
||||
|
||||
251
docs/src/content/docs/modules/rpc/client.md
Normal file
251
docs/src/content/docs/modules/rpc/client.md
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
title: "RPC 客户端 API"
|
||||
description: "RpcClient 连接 RPC 服务器"
|
||||
---
|
||||
|
||||
`RpcClient` 类提供类型安全的 WebSocket 客户端,用于 RPC 通信。
|
||||
|
||||
## 基础用法
|
||||
|
||||
```typescript
|
||||
import { RpcClient } from '@esengine/rpc/client';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
const client = new RpcClient(gameProtocol, 'ws://localhost:3000', {
|
||||
onConnect: () => console.log('已连接'),
|
||||
onDisconnect: (reason) => console.log('已断开:', reason),
|
||||
onError: (error) => console.error('错误:', error),
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
```
|
||||
|
||||
## 构造选项
|
||||
|
||||
```typescript
|
||||
interface RpcClientOptions {
|
||||
// 序列化编解码器(默认: json())
|
||||
codec?: Codec;
|
||||
|
||||
// API 调用超时,毫秒(默认: 30000)
|
||||
timeout?: number;
|
||||
|
||||
// 断开后自动重连(默认: true)
|
||||
autoReconnect?: boolean;
|
||||
|
||||
// 重连间隔,毫秒(默认: 3000)
|
||||
reconnectInterval?: number;
|
||||
|
||||
// 自定义 WebSocket 工厂(用于微信小游戏等)
|
||||
webSocketFactory?: (url: string) => WebSocketAdapter;
|
||||
|
||||
// 回调函数
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: (reason?: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## 连接管理
|
||||
|
||||
### 连接
|
||||
|
||||
```typescript
|
||||
// connect 返回 Promise
|
||||
await client.connect();
|
||||
|
||||
// 或链式调用
|
||||
client.connect().then(() => {
|
||||
console.log('已就绪');
|
||||
});
|
||||
```
|
||||
|
||||
### 检查状态
|
||||
|
||||
```typescript
|
||||
// 连接状态: 'connecting' | 'open' | 'closing' | 'closed'
|
||||
console.log(client.status);
|
||||
|
||||
// 便捷布尔值
|
||||
if (client.isConnected) {
|
||||
// 可以安全调用 API
|
||||
}
|
||||
```
|
||||
|
||||
### 断开连接
|
||||
|
||||
```typescript
|
||||
// 手动断开(禁用自动重连)
|
||||
client.disconnect();
|
||||
```
|
||||
|
||||
## 调用 API
|
||||
|
||||
API 使用请求-响应模式,完全类型安全:
|
||||
|
||||
```typescript
|
||||
// 定义协议
|
||||
const protocol = rpc.define({
|
||||
api: {
|
||||
login: rpc.api<{ username: string }, { userId: string; token: string }>(),
|
||||
getProfile: rpc.api<{ userId: string }, { name: string; level: number }>(),
|
||||
},
|
||||
msg: {}
|
||||
});
|
||||
|
||||
// 调用时类型自动推断
|
||||
const { userId, token } = await client.call('login', { username: 'player1' });
|
||||
const profile = await client.call('getProfile', { userId });
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
```typescript
|
||||
import { RpcError, ErrorCode } from '@esengine/rpc/client';
|
||||
|
||||
try {
|
||||
await client.call('login', { username: 'player1' });
|
||||
} catch (error) {
|
||||
if (error instanceof RpcError) {
|
||||
switch (error.code) {
|
||||
case ErrorCode.TIMEOUT:
|
||||
console.log('请求超时');
|
||||
break;
|
||||
case ErrorCode.CONNECTION_CLOSED:
|
||||
console.log('未连接');
|
||||
break;
|
||||
case ErrorCode.NOT_FOUND:
|
||||
console.log('API 不存在');
|
||||
break;
|
||||
default:
|
||||
console.log('服务器错误:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 发送消息
|
||||
|
||||
消息是发送即忘模式(无响应):
|
||||
|
||||
```typescript
|
||||
// 向服务器发送消息
|
||||
client.send('playerMove', { x: 100, y: 200 });
|
||||
client.send('chat', { text: 'Hello!' });
|
||||
```
|
||||
|
||||
## 接收消息
|
||||
|
||||
监听服务器推送的消息:
|
||||
|
||||
```typescript
|
||||
// 订阅消息
|
||||
client.on('newMessage', (data) => {
|
||||
console.log(`${data.from}: ${data.text}`);
|
||||
});
|
||||
|
||||
client.on('playerJoined', (data) => {
|
||||
console.log(`${data.name} 加入游戏`);
|
||||
});
|
||||
|
||||
// 取消特定处理器
|
||||
const handler = (data) => console.log(data);
|
||||
client.on('event', handler);
|
||||
client.off('event', handler);
|
||||
|
||||
// 取消某消息的所有处理器
|
||||
client.off('event');
|
||||
|
||||
// 一次性监听
|
||||
client.once('gameStart', (data) => {
|
||||
console.log('游戏开始!');
|
||||
});
|
||||
```
|
||||
|
||||
## 自定义 WebSocket(平台适配器)
|
||||
|
||||
用于微信小游戏等平台:
|
||||
|
||||
```typescript
|
||||
// 微信小游戏适配器
|
||||
const wxWebSocketFactory = (url: string) => {
|
||||
const ws = wx.connectSocket({ url });
|
||||
|
||||
return {
|
||||
get readyState() { return ws.readyState; },
|
||||
send: (data) => ws.send({ data }),
|
||||
close: (code, reason) => ws.close({ code, reason }),
|
||||
set onopen(fn) { ws.onOpen(fn); },
|
||||
set onclose(fn) { ws.onClose((e) => fn({ code: e.code, reason: e.reason })); },
|
||||
set onerror(fn) { ws.onError(fn); },
|
||||
set onmessage(fn) { ws.onMessage((e) => fn({ data: e.data })); },
|
||||
};
|
||||
};
|
||||
|
||||
const client = new RpcClient(protocol, 'wss://game.example.com', {
|
||||
webSocketFactory: wxWebSocketFactory,
|
||||
});
|
||||
```
|
||||
|
||||
## 便捷函数
|
||||
|
||||
```typescript
|
||||
import { connect } from '@esengine/rpc/client';
|
||||
|
||||
// 一次调用完成连接
|
||||
const client = await connect(protocol, 'ws://localhost:3000', {
|
||||
onConnect: () => console.log('已连接'),
|
||||
});
|
||||
|
||||
const result = await client.call('join', { name: 'Alice' });
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import { RpcClient } from '@esengine/rpc/client';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
class GameClient {
|
||||
private client: RpcClient<typeof gameProtocol>;
|
||||
private userId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.client = new RpcClient(gameProtocol, 'ws://localhost:3000', {
|
||||
onConnect: () => this.onConnected(),
|
||||
onDisconnect: () => this.onDisconnected(),
|
||||
onError: (e) => console.error('RPC 错误:', e),
|
||||
});
|
||||
|
||||
// 设置消息处理器
|
||||
this.client.on('gameState', (state) => this.updateState(state));
|
||||
this.client.on('playerJoined', (p) => this.addPlayer(p));
|
||||
this.client.on('playerLeft', (p) => this.removePlayer(p));
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await this.client.connect();
|
||||
}
|
||||
|
||||
private async onConnected() {
|
||||
const { userId, token } = await this.client.call('login', {
|
||||
username: localStorage.getItem('username') || 'Guest',
|
||||
});
|
||||
this.userId = userId;
|
||||
console.log('登录为', userId);
|
||||
}
|
||||
|
||||
private onDisconnected() {
|
||||
console.log('已断开,将自动重连...');
|
||||
}
|
||||
|
||||
async move(x: number, y: number) {
|
||||
if (!this.client.isConnected) return;
|
||||
this.client.send('move', { x, y });
|
||||
}
|
||||
|
||||
async chat(text: string) {
|
||||
await this.client.call('sendChat', { text });
|
||||
}
|
||||
}
|
||||
```
|
||||
160
docs/src/content/docs/modules/rpc/codec.md
Normal file
160
docs/src/content/docs/modules/rpc/codec.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: "RPC 编解码器"
|
||||
description: "RPC 通信的序列化编解码器"
|
||||
---
|
||||
|
||||
编解码器负责 RPC 消息的序列化和反序列化。内置两种编解码器。
|
||||
|
||||
## 内置编解码器
|
||||
|
||||
### JSON 编解码器(默认)
|
||||
|
||||
人类可读,兼容性好:
|
||||
|
||||
```typescript
|
||||
import { json } from '@esengine/rpc/codec';
|
||||
|
||||
const client = new RpcClient(protocol, url, {
|
||||
codec: json(),
|
||||
});
|
||||
```
|
||||
|
||||
**优点:**
|
||||
- 人类可读(方便调试)
|
||||
- 无额外依赖
|
||||
- 浏览器普遍支持
|
||||
|
||||
**缺点:**
|
||||
- 消息体积较大
|
||||
- 序列化速度较慢
|
||||
|
||||
### MessagePack 编解码器
|
||||
|
||||
二进制格式,更高效:
|
||||
|
||||
```typescript
|
||||
import { msgpack } from '@esengine/rpc/codec';
|
||||
|
||||
const client = new RpcClient(protocol, url, {
|
||||
codec: msgpack(),
|
||||
});
|
||||
```
|
||||
|
||||
**优点:**
|
||||
- 消息体积更小(约小30-50%)
|
||||
- 序列化速度更快
|
||||
- 原生支持二进制数据
|
||||
|
||||
**缺点:**
|
||||
- 不可读
|
||||
- 需要 msgpack 库
|
||||
|
||||
## 编解码器接口
|
||||
|
||||
```typescript
|
||||
interface Codec {
|
||||
/**
|
||||
* 将数据包编码为传输格式
|
||||
*/
|
||||
encode(packet: unknown): string | Uint8Array;
|
||||
|
||||
/**
|
||||
* 将传输格式解码为数据包
|
||||
*/
|
||||
decode(data: string | Uint8Array): unknown;
|
||||
}
|
||||
```
|
||||
|
||||
## 自定义编解码器
|
||||
|
||||
为特殊需求创建自己的编解码器:
|
||||
|
||||
```typescript
|
||||
import type { Codec } from '@esengine/rpc/codec';
|
||||
|
||||
// 示例:压缩 JSON 编解码器
|
||||
const compressedJson: () => Codec = () => ({
|
||||
encode(packet: unknown): Uint8Array {
|
||||
const json = JSON.stringify(packet);
|
||||
return compress(new TextEncoder().encode(json));
|
||||
},
|
||||
|
||||
decode(data: string | Uint8Array): unknown {
|
||||
const bytes = typeof data === 'string'
|
||||
? new TextEncoder().encode(data)
|
||||
: data;
|
||||
const decompressed = decompress(bytes);
|
||||
return JSON.parse(new TextDecoder().decode(decompressed));
|
||||
},
|
||||
});
|
||||
|
||||
// 使用自定义编解码器
|
||||
const client = new RpcClient(protocol, url, {
|
||||
codec: compressedJson(),
|
||||
});
|
||||
```
|
||||
|
||||
## Protocol Buffers 编解码器
|
||||
|
||||
对于生产级游戏,考虑使用 Protocol Buffers:
|
||||
|
||||
```typescript
|
||||
import type { Codec } from '@esengine/rpc/codec';
|
||||
|
||||
const protobuf = (schema: ProtobufSchema): Codec => ({
|
||||
encode(packet: unknown): Uint8Array {
|
||||
return schema.Packet.encode(packet).finish();
|
||||
},
|
||||
|
||||
decode(data: string | Uint8Array): unknown {
|
||||
const bytes = typeof data === 'string'
|
||||
? new TextEncoder().encode(data)
|
||||
: data;
|
||||
return schema.Packet.decode(bytes);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 客户端与服务器匹配
|
||||
|
||||
客户端和服务器必须使用相同的编解码器:
|
||||
|
||||
```typescript
|
||||
// shared/codec.ts
|
||||
import { msgpack } from '@esengine/rpc/codec';
|
||||
export const gameCodec = msgpack();
|
||||
|
||||
// client.ts
|
||||
import { gameCodec } from './shared/codec';
|
||||
const client = new RpcClient(protocol, url, { codec: gameCodec });
|
||||
|
||||
// server.ts
|
||||
import { gameCodec } from './shared/codec';
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
codec: gameCodec,
|
||||
api: { /* ... */ },
|
||||
});
|
||||
```
|
||||
|
||||
## 性能对比
|
||||
|
||||
| 编解码器 | 编码速度 | 解码速度 | 体积 |
|
||||
|----------|----------|----------|------|
|
||||
| JSON | 中等 | 中等 | 大 |
|
||||
| MessagePack | 快 | 快 | 小 |
|
||||
| Protobuf | 最快 | 最快 | 最小 |
|
||||
|
||||
对于大多数游戏,MessagePack 提供了良好的平衡。对于高性能需求使用 Protobuf。
|
||||
|
||||
## 文本编码工具
|
||||
|
||||
为自定义编解码器提供工具函数:
|
||||
|
||||
```typescript
|
||||
import { textEncode, textDecode } from '@esengine/rpc/codec';
|
||||
|
||||
// 在所有平台上工作(浏览器、Node.js、微信)
|
||||
const bytes = textEncode('Hello'); // Uint8Array
|
||||
const text = textDecode(bytes); // 'Hello'
|
||||
```
|
||||
350
docs/src/content/docs/modules/rpc/server.md
Normal file
350
docs/src/content/docs/modules/rpc/server.md
Normal file
@@ -0,0 +1,350 @@
|
||||
---
|
||||
title: "RPC 服务器 API"
|
||||
description: "RpcServer 处理客户端连接"
|
||||
---
|
||||
|
||||
`serve` 函数创建类型安全的 RPC 服务器,处理客户端连接和 API 调用。
|
||||
|
||||
## 基础用法
|
||||
|
||||
```typescript
|
||||
import { serve } from '@esengine/rpc/server';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
const server = serve(gameProtocol, {
|
||||
port: 3000,
|
||||
api: {
|
||||
login: async (input, conn) => {
|
||||
console.log(`${input.username} 从 ${conn.ip} 连接`);
|
||||
return { userId: conn.id, token: generateToken() };
|
||||
},
|
||||
sendChat: async (input, conn) => {
|
||||
server.broadcast('newMessage', {
|
||||
from: conn.id,
|
||||
text: input.text,
|
||||
time: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
onStart: (port) => console.log(`服务器启动于端口 ${port}`),
|
||||
});
|
||||
|
||||
await server.start();
|
||||
```
|
||||
|
||||
## 服务器选项
|
||||
|
||||
```typescript
|
||||
interface ServeOptions<P, TConnData> {
|
||||
// 必需
|
||||
port: number;
|
||||
api: ApiHandlers<P, TConnData>;
|
||||
|
||||
// 可选
|
||||
msg?: MsgHandlers<P, TConnData>;
|
||||
codec?: Codec;
|
||||
createConnData?: () => TConnData;
|
||||
|
||||
// 回调
|
||||
onConnect?: (conn: Connection<TConnData>) => void | Promise<void>;
|
||||
onDisconnect?: (conn: Connection<TConnData>, reason?: string) => void | Promise<void>;
|
||||
onError?: (error: Error, conn?: Connection<TConnData>) => void;
|
||||
onStart?: (port: number) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## API 处理器
|
||||
|
||||
每个 API 处理器接收输入和连接上下文:
|
||||
|
||||
```typescript
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
api: {
|
||||
// 同步处理器
|
||||
ping: (input, conn) => {
|
||||
return { pong: true, time: Date.now() };
|
||||
},
|
||||
|
||||
// 异步处理器
|
||||
getProfile: async (input, conn) => {
|
||||
const user = await database.findUser(input.userId);
|
||||
return { name: user.name, level: user.level };
|
||||
},
|
||||
|
||||
// 访问连接上下文
|
||||
getMyInfo: (input, conn) => {
|
||||
return {
|
||||
connectionId: conn.id,
|
||||
ip: conn.ip,
|
||||
data: conn.data,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 抛出错误
|
||||
|
||||
```typescript
|
||||
import { RpcError, ErrorCode } from '@esengine/rpc/server';
|
||||
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
api: {
|
||||
login: async (input, conn) => {
|
||||
const user = await database.findUser(input.username);
|
||||
|
||||
if (!user) {
|
||||
throw new RpcError(ErrorCode.NOT_FOUND, '用户不存在');
|
||||
}
|
||||
|
||||
if (!await verifyPassword(input.password, user.hash)) {
|
||||
throw new RpcError('AUTH_FAILED', '密码错误');
|
||||
}
|
||||
|
||||
return { userId: user.id, token: generateToken() };
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 消息处理器
|
||||
|
||||
处理客户端发送的消息:
|
||||
|
||||
```typescript
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
api: { /* ... */ },
|
||||
msg: {
|
||||
playerMove: (data, conn) => {
|
||||
// 更新玩家位置
|
||||
const player = players.get(conn.id);
|
||||
if (player) {
|
||||
player.x = data.x;
|
||||
player.y = data.y;
|
||||
}
|
||||
|
||||
// 广播给其他玩家
|
||||
server.broadcast('playerMoved', {
|
||||
playerId: conn.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
}, { exclude: conn });
|
||||
},
|
||||
|
||||
chat: async (data, conn) => {
|
||||
// 异步处理器也可以
|
||||
await logChat(conn.id, data.text);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 连接上下文
|
||||
|
||||
`Connection` 对象提供客户端信息:
|
||||
|
||||
```typescript
|
||||
interface Connection<TData> {
|
||||
// 唯一连接 ID
|
||||
readonly id: string;
|
||||
|
||||
// 客户端 IP 地址
|
||||
readonly ip: string;
|
||||
|
||||
// 连接状态
|
||||
readonly isOpen: boolean;
|
||||
|
||||
// 附加到此连接的自定义数据
|
||||
data: TData;
|
||||
|
||||
// 关闭连接
|
||||
close(reason?: string): void;
|
||||
}
|
||||
```
|
||||
|
||||
### 自定义连接数据
|
||||
|
||||
存储每连接的状态:
|
||||
|
||||
```typescript
|
||||
interface PlayerData {
|
||||
playerId: string;
|
||||
username: string;
|
||||
room: string | null;
|
||||
}
|
||||
|
||||
const server = serve(protocol, {
|
||||
port: 3000,
|
||||
createConnData: () => ({
|
||||
playerId: '',
|
||||
username: '',
|
||||
room: null,
|
||||
} as PlayerData),
|
||||
api: {
|
||||
login: async (input, conn) => {
|
||||
// 在连接上存储数据
|
||||
conn.data.playerId = generateId();
|
||||
conn.data.username = input.username;
|
||||
return { playerId: conn.data.playerId };
|
||||
},
|
||||
joinRoom: async (input, conn) => {
|
||||
conn.data.room = input.roomId;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
onDisconnect: (conn) => {
|
||||
console.log(`${conn.data.username} 离开房间 ${conn.data.room}`);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 发送消息
|
||||
|
||||
### 发送给单个连接
|
||||
|
||||
```typescript
|
||||
server.send(conn, 'notification', { text: 'Hello!' });
|
||||
```
|
||||
|
||||
### 广播给所有人
|
||||
|
||||
```typescript
|
||||
// 给所有人
|
||||
server.broadcast('announcement', { text: '服务器将在5分钟后重启' });
|
||||
|
||||
// 排除发送者
|
||||
server.broadcast('playerMoved', { id: conn.id, x, y }, { exclude: conn });
|
||||
|
||||
// 排除多个
|
||||
server.broadcast('gameEvent', data, { exclude: [conn1, conn2] });
|
||||
```
|
||||
|
||||
### 发送给特定群组
|
||||
|
||||
```typescript
|
||||
// 自定义广播
|
||||
function broadcastToRoom(roomId: string, name: string, data: any) {
|
||||
for (const conn of server.connections) {
|
||||
if (conn.data.room === roomId) {
|
||||
server.send(conn, name, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcastToRoom('room1', 'roomMessage', { text: '房间内消息!' });
|
||||
```
|
||||
|
||||
## 服务器生命周期
|
||||
|
||||
```typescript
|
||||
const server = serve(protocol, { /* ... */ });
|
||||
|
||||
// 启动
|
||||
await server.start();
|
||||
console.log('服务器运行中');
|
||||
|
||||
// 访问连接列表
|
||||
console.log(`${server.connections.length} 个客户端已连接`);
|
||||
|
||||
// 停止(关闭所有连接)
|
||||
await server.stop();
|
||||
console.log('服务器已停止');
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import { serve, RpcError } from '@esengine/rpc/server';
|
||||
import { gameProtocol } from './protocol';
|
||||
|
||||
interface PlayerData {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const players = new Map<string, PlayerData>();
|
||||
|
||||
const server = serve(gameProtocol, {
|
||||
port: 3000,
|
||||
createConnData: () => ({ id: '', name: '', x: 0, y: 0 }),
|
||||
|
||||
api: {
|
||||
join: async (input, conn) => {
|
||||
const player: PlayerData = {
|
||||
id: conn.id,
|
||||
name: input.name,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
players.set(conn.id, player);
|
||||
conn.data = player;
|
||||
|
||||
// 通知其他玩家
|
||||
server.broadcast('playerJoined', {
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
}, { exclude: conn });
|
||||
|
||||
// 发送当前状态给新玩家
|
||||
return {
|
||||
playerId: player.id,
|
||||
players: Array.from(players.values()),
|
||||
};
|
||||
},
|
||||
|
||||
chat: async (input, conn) => {
|
||||
server.broadcast('chatMessage', {
|
||||
from: conn.data.name,
|
||||
text: input.text,
|
||||
time: Date.now(),
|
||||
});
|
||||
return { sent: true };
|
||||
},
|
||||
},
|
||||
|
||||
msg: {
|
||||
move: (data, conn) => {
|
||||
const player = players.get(conn.id);
|
||||
if (player) {
|
||||
player.x = data.x;
|
||||
player.y = data.y;
|
||||
|
||||
server.broadcast('playerMoved', {
|
||||
id: conn.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
}, { exclude: conn });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
onConnect: (conn) => {
|
||||
console.log(`客户端已连接: ${conn.id} 来自 ${conn.ip}`);
|
||||
},
|
||||
|
||||
onDisconnect: (conn) => {
|
||||
const player = players.get(conn.id);
|
||||
if (player) {
|
||||
players.delete(conn.id);
|
||||
server.broadcast('playerLeft', { id: conn.id });
|
||||
console.log(`${player.name} 已断开`);
|
||||
}
|
||||
},
|
||||
|
||||
onError: (error, conn) => {
|
||||
console.error(`来自 ${conn?.id} 的错误:`, error);
|
||||
},
|
||||
|
||||
onStart: (port) => {
|
||||
console.log(`游戏服务器运行于 ws://localhost:${port}`);
|
||||
},
|
||||
});
|
||||
|
||||
server.start();
|
||||
```
|
||||
167
docs/src/content/docs/modules/world-streaming/chunk-manager.md
Normal file
167
docs/src/content/docs/modules/world-streaming/chunk-manager.md
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
title: "区块管理器 API"
|
||||
description: "ChunkManager 负责区块生命周期、加载队列和空间查询"
|
||||
---
|
||||
|
||||
`ChunkManager` 是管理区块生命周期的核心服务,包括加载、卸载和空间查询。
|
||||
|
||||
## 基础用法
|
||||
|
||||
```typescript
|
||||
import { ChunkManager } from '@esengine/world-streaming';
|
||||
|
||||
// 创建 512 单位大小的区块管理器
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
|
||||
// 设置数据提供器
|
||||
chunkManager.setDataProvider(myProvider);
|
||||
|
||||
// 设置事件回调
|
||||
chunkManager.setEvents({
|
||||
onChunkLoaded: (coord, entities) => {
|
||||
console.log(`区块 (${coord.x}, ${coord.y}) 已加载,包含 ${entities.length} 个实体`);
|
||||
},
|
||||
onChunkUnloaded: (coord) => {
|
||||
console.log(`区块 (${coord.x}, ${coord.y}) 已卸载`);
|
||||
},
|
||||
onChunkLoadFailed: (coord, error) => {
|
||||
console.error(`加载区块 (${coord.x}, ${coord.y}) 失败:`, error);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 加载与卸载
|
||||
|
||||
### 请求加载
|
||||
|
||||
```typescript
|
||||
import { EChunkPriority } from '@esengine/world-streaming';
|
||||
|
||||
// 按优先级请求加载
|
||||
chunkManager.requestLoad({ x: 0, y: 0 }, EChunkPriority.Immediate);
|
||||
chunkManager.requestLoad({ x: 1, y: 0 }, EChunkPriority.High);
|
||||
chunkManager.requestLoad({ x: 2, y: 0 }, EChunkPriority.Normal);
|
||||
chunkManager.requestLoad({ x: 3, y: 0 }, EChunkPriority.Low);
|
||||
chunkManager.requestLoad({ x: 4, y: 0 }, EChunkPriority.Prefetch);
|
||||
```
|
||||
|
||||
### 优先级说明
|
||||
|
||||
| 优先级 | 值 | 说明 |
|
||||
|--------|------|------|
|
||||
| `Immediate` | 0 | 当前区块(玩家所在) |
|
||||
| `High` | 1 | 相邻区块 |
|
||||
| `Normal` | 2 | 附近区块 |
|
||||
| `Low` | 3 | 远处可见区块 |
|
||||
| `Prefetch` | 4 | 移动方向预加载 |
|
||||
|
||||
### 请求卸载
|
||||
|
||||
```typescript
|
||||
// 请求卸载,延迟 3 秒
|
||||
chunkManager.requestUnload({ x: 5, y: 5 }, 3000);
|
||||
|
||||
// 取消待卸载请求(玩家返回了)
|
||||
chunkManager.cancelUnload({ x: 5, y: 5 });
|
||||
```
|
||||
|
||||
### 处理队列
|
||||
|
||||
```typescript
|
||||
// 在更新循环或系统中
|
||||
await chunkManager.processLoads(2); // 每帧最多加载 2 个区块
|
||||
chunkManager.processUnloads(1); // 每帧最多卸载 1 个区块
|
||||
```
|
||||
|
||||
## 空间查询
|
||||
|
||||
### 坐标转换
|
||||
|
||||
```typescript
|
||||
// 世界坐标转区块坐标
|
||||
const coord = chunkManager.worldToChunk(1500, 2300);
|
||||
// 结果: { x: 2, y: 4 }(512单位区块)
|
||||
|
||||
// 获取区块世界边界
|
||||
const bounds = chunkManager.getChunkBounds({ x: 2, y: 4 });
|
||||
// 结果: { minX: 1024, minY: 2048, maxX: 1536, maxY: 2560 }
|
||||
```
|
||||
|
||||
### 区块查询
|
||||
|
||||
```typescript
|
||||
// 检查区块是否已加载
|
||||
if (chunkManager.isChunkLoaded({ x: 0, y: 0 })) {
|
||||
const chunk = chunkManager.getChunk({ x: 0, y: 0 });
|
||||
console.log('实体数量:', chunk.entities.length);
|
||||
}
|
||||
|
||||
// 获取半径内未加载的区块
|
||||
const missing = chunkManager.getMissingChunks({ x: 0, y: 0 }, 2);
|
||||
for (const coord of missing) {
|
||||
chunkManager.requestLoad(coord);
|
||||
}
|
||||
|
||||
// 获取超出范围的区块(用于卸载)
|
||||
const outside = chunkManager.getChunksOutsideRadius({ x: 0, y: 0 }, 4);
|
||||
for (const coord of outside) {
|
||||
chunkManager.requestUnload(coord, 3000);
|
||||
}
|
||||
|
||||
// 遍历所有已加载区块
|
||||
chunkManager.forEachChunk((info, coord) => {
|
||||
console.log(`区块 (${coord.x}, ${coord.y}): ${info.state}`);
|
||||
});
|
||||
```
|
||||
|
||||
## 统计信息
|
||||
|
||||
```typescript
|
||||
console.log('已加载区块:', chunkManager.loadedChunkCount);
|
||||
console.log('待加载:', chunkManager.pendingLoadCount);
|
||||
console.log('待卸载:', chunkManager.pendingUnloadCount);
|
||||
console.log('区块大小:', chunkManager.chunkSize);
|
||||
```
|
||||
|
||||
## 区块状态
|
||||
|
||||
```typescript
|
||||
import { EChunkState } from '@esengine/world-streaming';
|
||||
|
||||
// 区块生命周期状态
|
||||
EChunkState.Unloaded // 未加载
|
||||
EChunkState.Loading // 加载中
|
||||
EChunkState.Loaded // 已加载
|
||||
EChunkState.Unloading // 卸载中
|
||||
EChunkState.Failed // 加载失败
|
||||
```
|
||||
|
||||
## 数据提供器接口
|
||||
|
||||
```typescript
|
||||
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||
|
||||
class MyChunkProvider implements IChunkDataProvider {
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
// 从数据库、文件或程序化生成加载
|
||||
const data = await fetchChunkFromServer(coord);
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
// 保存修改过的区块
|
||||
await saveChunkToServer(data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 清理
|
||||
|
||||
```typescript
|
||||
// 卸载所有区块
|
||||
chunkManager.clear();
|
||||
|
||||
// 完全释放(实现 IService 接口)
|
||||
chunkManager.dispose();
|
||||
```
|
||||
330
docs/src/content/docs/modules/world-streaming/examples.md
Normal file
330
docs/src/content/docs/modules/world-streaming/examples.md
Normal file
@@ -0,0 +1,330 @@
|
||||
---
|
||||
title: "示例"
|
||||
description: "世界流式加载实践示例"
|
||||
---
|
||||
|
||||
## 无限程序化世界
|
||||
|
||||
无限大世界的程序化资源生成示例。
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChunkManager,
|
||||
ChunkStreamingSystem,
|
||||
ChunkLoaderComponent,
|
||||
StreamingAnchorComponent
|
||||
} from '@esengine/world-streaming';
|
||||
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||
|
||||
// 程序化世界生成器
|
||||
class WorldGenerator implements IChunkDataProvider {
|
||||
private seed: number;
|
||||
private nextEntityId = 1;
|
||||
|
||||
constructor(seed: number = 12345) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const rng = this.createChunkRNG(coord);
|
||||
const entities = [];
|
||||
|
||||
// 每区块生成 5-15 个资源
|
||||
const resourceCount = 5 + Math.floor(rng() * 10);
|
||||
|
||||
for (let i = 0; i < resourceCount; i++) {
|
||||
const type = this.randomResourceType(rng);
|
||||
|
||||
entities.push({
|
||||
name: `Resource_${this.nextEntityId++}`,
|
||||
localPosition: {
|
||||
x: rng() * 512,
|
||||
y: rng() * 512
|
||||
},
|
||||
components: {
|
||||
ResourceNode: {
|
||||
type,
|
||||
amount: this.getResourceAmount(type, rng),
|
||||
regenRate: this.getRegenRate(type)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { coord, entities, version: 1 };
|
||||
}
|
||||
|
||||
async saveChunkData(_data: IChunkData): Promise<void> {
|
||||
// 程序化生成 - 无需持久化
|
||||
}
|
||||
|
||||
private createChunkRNG(coord: IChunkCoord) {
|
||||
let seed = this.seed ^ (coord.x * 73856093) ^ (coord.y * 19349663);
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
private randomResourceType(rng: () => number) {
|
||||
const types = ['energyWell', 'oreVein', 'crystalDeposit'];
|
||||
const weights = [0.5, 0.35, 0.15];
|
||||
|
||||
let random = rng();
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
random -= weights[i];
|
||||
if (random <= 0) return types[i];
|
||||
}
|
||||
return types[0];
|
||||
}
|
||||
|
||||
private getResourceAmount(type: string, rng: () => number) {
|
||||
switch (type) {
|
||||
case 'energyWell': return 300 + Math.floor(rng() * 200);
|
||||
case 'oreVein': return 500 + Math.floor(rng() * 300);
|
||||
case 'crystalDeposit': return 100 + Math.floor(rng() * 100);
|
||||
default: return 100;
|
||||
}
|
||||
}
|
||||
|
||||
private getRegenRate(type: string) {
|
||||
switch (type) {
|
||||
case 'energyWell': return 2;
|
||||
case 'oreVein': return 1;
|
||||
case 'crystalDeposit': return 0.2;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
chunkManager.setDataProvider(new WorldGenerator(12345));
|
||||
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
streamingSystem.setChunkManager(chunkManager);
|
||||
scene.addSystem(streamingSystem);
|
||||
```
|
||||
|
||||
## MMO 服务端区块
|
||||
|
||||
带数据库持久化的 MMO 服务端区块管理。
|
||||
|
||||
```typescript
|
||||
class ServerChunkProvider implements IChunkDataProvider {
|
||||
private db: Database;
|
||||
private cache = new Map<string, IChunkData>();
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const key = `${coord.x},${coord.y}`;
|
||||
|
||||
// 检查缓存
|
||||
if (this.cache.has(key)) {
|
||||
return this.cache.get(key)!;
|
||||
}
|
||||
|
||||
// 从数据库加载
|
||||
const row = await this.db.query(
|
||||
'SELECT data FROM chunks WHERE x = ? AND y = ?',
|
||||
[coord.x, coord.y]
|
||||
);
|
||||
|
||||
if (row) {
|
||||
const data = JSON.parse(row.data);
|
||||
this.cache.set(key, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
// 生成新区块
|
||||
const data = this.generateChunk(coord);
|
||||
await this.saveChunkData(data);
|
||||
this.cache.set(key, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
const key = `${data.coord.x},${data.coord.y}`;
|
||||
this.cache.set(key, data);
|
||||
|
||||
await this.db.query(
|
||||
`INSERT INTO chunks (x, y, data) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE data = VALUES(data)`,
|
||||
[data.coord.x, data.coord.y, JSON.stringify(data)]
|
||||
);
|
||||
}
|
||||
|
||||
private generateChunk(coord: IChunkCoord): IChunkData {
|
||||
// 新区块的程序化生成
|
||||
return { coord, entities: [], version: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端按玩家加载区块
|
||||
class PlayerChunkManager {
|
||||
private chunkManager: ChunkManager;
|
||||
private playerChunks = new Map<string, Set<string>>();
|
||||
|
||||
async updatePlayerPosition(playerId: string, x: number, y: number) {
|
||||
const centerCoord = this.chunkManager.worldToChunk(x, y);
|
||||
const loadRadius = 2;
|
||||
|
||||
const newChunks = new Set<string>();
|
||||
|
||||
// 加载玩家周围的区块
|
||||
for (let dx = -loadRadius; dx <= loadRadius; dx++) {
|
||||
for (let dy = -loadRadius; dy <= loadRadius; dy++) {
|
||||
const coord = { x: centerCoord.x + dx, y: centerCoord.y + dy };
|
||||
const key = `${coord.x},${coord.y}`;
|
||||
newChunks.add(key);
|
||||
|
||||
if (!this.chunkManager.isChunkLoaded(coord)) {
|
||||
await this.chunkManager.requestLoad(coord);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 记录玩家已加载的区块
|
||||
this.playerChunks.set(playerId, newChunks);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 瓦片地图世界
|
||||
|
||||
瓦片地图与区块流式加载集成。
|
||||
|
||||
```typescript
|
||||
import { TilemapComponent } from '@esengine/tilemap';
|
||||
|
||||
class TilemapChunkProvider implements IChunkDataProvider {
|
||||
private tilemapData: number[][]; // 完整瓦片地图
|
||||
private tileSize = 32;
|
||||
private chunkTiles = 16; // 每区块 16x16 瓦片
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const startTileX = coord.x * this.chunkTiles;
|
||||
const startTileY = coord.y * this.chunkTiles;
|
||||
|
||||
// 提取此区块的瓦片
|
||||
const tiles: number[][] = [];
|
||||
for (let y = 0; y < this.chunkTiles; y++) {
|
||||
const row: number[] = [];
|
||||
for (let x = 0; x < this.chunkTiles; x++) {
|
||||
const tileX = startTileX + x;
|
||||
const tileY = startTileY + y;
|
||||
row.push(this.getTile(tileX, tileY));
|
||||
}
|
||||
tiles.push(row);
|
||||
}
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities: [{
|
||||
name: `TileChunk_${coord.x}_${coord.y}`,
|
||||
localPosition: { x: 0, y: 0 },
|
||||
components: {
|
||||
TilemapChunk: { tiles }
|
||||
}
|
||||
}],
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
private getTile(x: number, y: number): number {
|
||||
if (x < 0 || y < 0 || y >= this.tilemapData.length) {
|
||||
return 0; // 超出边界 = 空
|
||||
}
|
||||
return this.tilemapData[y]?.[x] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 瓦片地图自定义序列化器
|
||||
class TilemapSerializer extends ChunkSerializer {
|
||||
protected deserializeComponents(entity: Entity, components: Record<string, unknown>): void {
|
||||
if (components.TilemapChunk) {
|
||||
const data = components.TilemapChunk as { tiles: number[][] };
|
||||
const tilemap = entity.addComponent(new TilemapComponent());
|
||||
tilemap.loadTiles(data.tiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 动态加载事件
|
||||
|
||||
响应区块加载用于游戏逻辑。
|
||||
|
||||
```typescript
|
||||
chunkManager.setEvents({
|
||||
onChunkLoaded: (coord, entities) => {
|
||||
// 启用物理
|
||||
for (const entity of entities) {
|
||||
const collider = entity.getComponent(ColliderComponent);
|
||||
collider?.enable();
|
||||
}
|
||||
|
||||
// 为已加载区块生成 NPC
|
||||
npcManager.spawnForChunk(coord);
|
||||
|
||||
// 更新战争迷雾
|
||||
fogOfWar.revealChunk(coord);
|
||||
|
||||
// 通知客户端(服务端)
|
||||
broadcast('ChunkLoaded', { coord, entityCount: entities.length });
|
||||
},
|
||||
|
||||
onChunkUnloaded: (coord) => {
|
||||
// 保存 NPC 状态
|
||||
npcManager.saveAndRemoveForChunk(coord);
|
||||
|
||||
// 更新迷雾
|
||||
fogOfWar.hideChunk(coord);
|
||||
|
||||
// 通知客户端
|
||||
broadcast('ChunkUnloaded', { coord });
|
||||
},
|
||||
|
||||
onChunkLoadFailed: (coord, error) => {
|
||||
console.error(`加载区块 ${coord.x},${coord.y} 失败:`, error);
|
||||
|
||||
// 延迟后重试
|
||||
setTimeout(() => {
|
||||
chunkManager.requestLoad(coord);
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
```typescript
|
||||
// 根据设备性能调整
|
||||
function configureForDevice(loader: ChunkLoaderComponent) {
|
||||
const memory = navigator.deviceMemory ?? 4;
|
||||
const cores = navigator.hardwareConcurrency ?? 4;
|
||||
|
||||
if (memory <= 2 || cores <= 2) {
|
||||
// 低端设备
|
||||
loader.loadRadius = 1;
|
||||
loader.unloadRadius = 2;
|
||||
loader.maxLoadsPerFrame = 1;
|
||||
loader.bEnablePrefetch = false;
|
||||
} else if (memory <= 4) {
|
||||
// 中端设备
|
||||
loader.loadRadius = 2;
|
||||
loader.unloadRadius = 3;
|
||||
loader.maxLoadsPerFrame = 2;
|
||||
} else {
|
||||
// 高端设备
|
||||
loader.loadRadius = 3;
|
||||
loader.unloadRadius = 5;
|
||||
loader.maxLoadsPerFrame = 4;
|
||||
loader.prefetchRadius = 2;
|
||||
}
|
||||
}
|
||||
```
|
||||
158
docs/src/content/docs/modules/world-streaming/index.md
Normal file
158
docs/src/content/docs/modules/world-streaming/index.md
Normal file
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: "世界流式加载"
|
||||
description: "基于区块的开放世界流式加载系统"
|
||||
---
|
||||
|
||||
`@esengine/world-streaming` 提供基于区块的世界流式加载与管理,适用于开放世界游戏。根据玩家位置动态加载/卸载世界区块。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @esengine/world-streaming
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 基础设置
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChunkManager,
|
||||
ChunkStreamingSystem,
|
||||
StreamingAnchorComponent,
|
||||
ChunkLoaderComponent
|
||||
} from '@esengine/world-streaming';
|
||||
|
||||
// 创建区块管理器 (512单位区块)
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
|
||||
// 添加流式加载系统
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
streamingSystem.setChunkManager(chunkManager);
|
||||
scene.addSystem(streamingSystem);
|
||||
|
||||
// 创建加载器实体
|
||||
const loaderEntity = scene.createEntity('ChunkLoader');
|
||||
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
|
||||
loader.chunkSize = 512;
|
||||
loader.loadRadius = 2;
|
||||
loader.unloadRadius = 4;
|
||||
|
||||
// 创建玩家作为流式锚点
|
||||
const playerEntity = scene.createEntity('Player');
|
||||
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
|
||||
|
||||
// 每帧更新锚点位置
|
||||
function update() {
|
||||
anchor.x = player.position.x;
|
||||
anchor.y = player.position.y;
|
||||
}
|
||||
```
|
||||
|
||||
### 程序化生成
|
||||
|
||||
```typescript
|
||||
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||
|
||||
class ProceduralChunkProvider implements IChunkDataProvider {
|
||||
private seed: number;
|
||||
|
||||
constructor(seed: number) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
// 使用种子+坐标生成确定性随机数
|
||||
const chunkSeed = this.hashCoord(coord);
|
||||
const rng = this.createRNG(chunkSeed);
|
||||
|
||||
// 生成区块内容
|
||||
const entities = this.generateEntities(coord, rng);
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities,
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
// 可选:持久化已修改的区块
|
||||
}
|
||||
|
||||
private hashCoord(coord: IChunkCoord): number {
|
||||
return this.seed ^ (coord.x * 73856093) ^ (coord.y * 19349663);
|
||||
}
|
||||
|
||||
private createRNG(seed: number) {
|
||||
// 简单的种子随机数生成器
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
private generateEntities(coord: IChunkCoord, rng: () => number) {
|
||||
// 生成资源、树木等
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 使用数据提供器
|
||||
chunkManager.setDataProvider(new ProceduralChunkProvider(12345));
|
||||
```
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 区块生命周期
|
||||
|
||||
```
|
||||
未加载 → 加载中 → 已加载 → 卸载中 → 未加载
|
||||
↓ ↓
|
||||
失败 (发生错误时)
|
||||
```
|
||||
|
||||
### 流式锚点
|
||||
|
||||
`StreamingAnchorComponent` 用于标记作为区块加载锚点的实体。系统会在所有锚点周围加载区块,在超出范围时卸载区块。
|
||||
|
||||
```typescript
|
||||
// StreamingAnchorComponent 实现 IPositionable 接口
|
||||
interface IPositionable {
|
||||
readonly position: { x: number; y: number };
|
||||
}
|
||||
```
|
||||
|
||||
### 配置参数
|
||||
|
||||
| 属性 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `chunkSize` | 512 | 区块大小(世界单位) |
|
||||
| `loadRadius` | 2 | 锚点周围加载的区块半径 |
|
||||
| `unloadRadius` | 4 | 超过此半径的区块会被卸载 |
|
||||
| `maxLoadsPerFrame` | 2 | 每帧最大异步加载数 |
|
||||
| `unloadDelay` | 3000 | 卸载前的延迟(毫秒) |
|
||||
| `bEnablePrefetch` | true | 沿移动方向预加载 |
|
||||
|
||||
## 模块设置(可选)
|
||||
|
||||
使用模块辅助函数快速配置:
|
||||
|
||||
```typescript
|
||||
import { worldStreamingModule } from '@esengine/world-streaming';
|
||||
|
||||
const chunkManager = worldStreamingModule.setup(
|
||||
scene,
|
||||
services,
|
||||
componentRegistry,
|
||||
{ chunkSize: 256, bEnableCulling: true }
|
||||
);
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- [区块管理器 API](./chunk-manager) - 加载队列、区块生命周期
|
||||
- [流式系统](./streaming-system) - 基于锚点的加载
|
||||
- [序列化](./serialization) - 自定义区块序列化
|
||||
- [示例](./examples) - 程序化世界、MMO 区块
|
||||
227
docs/src/content/docs/modules/world-streaming/serialization.md
Normal file
227
docs/src/content/docs/modules/world-streaming/serialization.md
Normal file
@@ -0,0 +1,227 @@
|
||||
---
|
||||
title: "区块序列化"
|
||||
description: "自定义区块数据序列化"
|
||||
---
|
||||
|
||||
`ChunkSerializer` 负责实体数据与区块存储格式之间的转换。
|
||||
|
||||
## 默认序列化器
|
||||
|
||||
```typescript
|
||||
import { ChunkSerializer, ChunkManager } from '@esengine/world-streaming';
|
||||
|
||||
const serializer = new ChunkSerializer();
|
||||
const chunkManager = new ChunkManager(512, serializer);
|
||||
```
|
||||
|
||||
## 自定义序列化器
|
||||
|
||||
继承 `ChunkSerializer` 实现自定义序列化逻辑:
|
||||
|
||||
```typescript
|
||||
import { ChunkSerializer } from '@esengine/world-streaming';
|
||||
import type { Entity, IScene } from '@esengine/ecs-framework';
|
||||
import type { IChunkCoord, IChunkData, IChunkBounds } from '@esengine/world-streaming';
|
||||
|
||||
class GameChunkSerializer extends ChunkSerializer {
|
||||
/**
|
||||
* 获取实体位置
|
||||
* 重写以使用你的位置组件
|
||||
*/
|
||||
protected getPositionable(entity: Entity) {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
if (transform) {
|
||||
return { position: { x: transform.x, y: transform.y } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反序列化后设置实体位置
|
||||
*/
|
||||
protected setEntityPosition(entity: Entity, x: number, y: number): void {
|
||||
const transform = entity.addComponent(new TransformComponent());
|
||||
transform.x = x;
|
||||
transform.y = y;
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化组件
|
||||
*/
|
||||
protected serializeComponents(entity: Entity): Record<string, unknown> {
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
const resource = entity.getComponent(ResourceComponent);
|
||||
if (resource) {
|
||||
data.ResourceComponent = {
|
||||
type: resource.type,
|
||||
amount: resource.amount,
|
||||
maxAmount: resource.maxAmount
|
||||
};
|
||||
}
|
||||
|
||||
const npc = entity.getComponent(NPCComponent);
|
||||
if (npc) {
|
||||
data.NPCComponent = {
|
||||
id: npc.id,
|
||||
state: npc.state
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反序列化组件
|
||||
*/
|
||||
protected deserializeComponents(entity: Entity, components: Record<string, unknown>): void {
|
||||
if (components.ResourceComponent) {
|
||||
const data = components.ResourceComponent as any;
|
||||
const resource = entity.addComponent(new ResourceComponent());
|
||||
resource.type = data.type;
|
||||
resource.amount = data.amount;
|
||||
resource.maxAmount = data.maxAmount;
|
||||
}
|
||||
|
||||
if (components.NPCComponent) {
|
||||
const data = components.NPCComponent as any;
|
||||
const npc = entity.addComponent(new NPCComponent());
|
||||
npc.id = data.id;
|
||||
npc.state = data.state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤需要序列化的组件
|
||||
*/
|
||||
protected shouldSerializeComponent(componentName: string): boolean {
|
||||
const include = ['ResourceComponent', 'NPCComponent', 'BuildingComponent'];
|
||||
return include.includes(componentName);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 区块数据格式
|
||||
|
||||
```typescript
|
||||
interface IChunkData {
|
||||
coord: IChunkCoord; // 区块坐标
|
||||
entities: ISerializedEntity[]; // 实体数据
|
||||
version: number; // 数据版本
|
||||
}
|
||||
|
||||
interface ISerializedEntity {
|
||||
name: string; // 实体名称
|
||||
localPosition: { x: number; y: number }; // 区块内位置
|
||||
components: Record<string, unknown>; // 组件数据
|
||||
}
|
||||
|
||||
interface IChunkCoord {
|
||||
x: number; // 区块 X 坐标
|
||||
y: number; // 区块 Y 坐标
|
||||
}
|
||||
```
|
||||
|
||||
## 带序列化的数据提供器
|
||||
|
||||
```typescript
|
||||
class DatabaseChunkProvider implements IChunkDataProvider {
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const key = `chunk_${coord.x}_${coord.y}`;
|
||||
const json = await database.get(key);
|
||||
|
||||
if (!json) return null;
|
||||
return JSON.parse(json) as IChunkData;
|
||||
}
|
||||
|
||||
async saveChunkData(data: IChunkData): Promise<void> {
|
||||
const key = `chunk_${data.coord.x}_${data.coord.y}`;
|
||||
await database.set(key, JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 程序化生成与序列化器
|
||||
|
||||
```typescript
|
||||
class ProceduralProvider implements IChunkDataProvider {
|
||||
private serializer: GameChunkSerializer;
|
||||
|
||||
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||
const entities = this.generateEntities(coord);
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities,
|
||||
version: 1
|
||||
};
|
||||
}
|
||||
|
||||
private generateEntities(coord: IChunkCoord): ISerializedEntity[] {
|
||||
const entities: ISerializedEntity[] = [];
|
||||
const rng = this.createRNG(coord);
|
||||
|
||||
// 生成树木
|
||||
const treeCount = Math.floor(rng() * 10);
|
||||
for (let i = 0; i < treeCount; i++) {
|
||||
entities.push({
|
||||
name: `Tree_${coord.x}_${coord.y}_${i}`,
|
||||
localPosition: {
|
||||
x: rng() * 512,
|
||||
y: rng() * 512
|
||||
},
|
||||
components: {
|
||||
TreeComponent: { type: 'oak', health: 100 }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 生成资源
|
||||
if (rng() > 0.7) {
|
||||
entities.push({
|
||||
name: `Resource_${coord.x}_${coord.y}`,
|
||||
localPosition: { x: 256, y: 256 },
|
||||
components: {
|
||||
ResourceComponent: {
|
||||
type: 'iron',
|
||||
amount: 500,
|
||||
maxAmount: 500
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 版本迁移
|
||||
|
||||
```typescript
|
||||
class VersionedSerializer extends ChunkSerializer {
|
||||
private static readonly CURRENT_VERSION = 2;
|
||||
|
||||
deserialize(data: IChunkData, scene: IScene): Entity[] {
|
||||
// 迁移旧数据
|
||||
if (data.version < 2) {
|
||||
data = this.migrateV1toV2(data);
|
||||
}
|
||||
|
||||
return super.deserialize(data, scene);
|
||||
}
|
||||
|
||||
private migrateV1toV2(data: IChunkData): IChunkData {
|
||||
// 转换旧组件格式
|
||||
for (const entity of data.entities) {
|
||||
if (entity.components.OldResource) {
|
||||
entity.components.ResourceComponent = entity.components.OldResource;
|
||||
delete entity.components.OldResource;
|
||||
}
|
||||
}
|
||||
|
||||
data.version = 2;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
title: "流式加载系统"
|
||||
description: "ChunkStreamingSystem 根据锚点位置自动管理区块加载"
|
||||
---
|
||||
|
||||
`ChunkStreamingSystem` 根据 `StreamingAnchorComponent` 的位置自动管理区块的加载和卸载。
|
||||
|
||||
## 设置
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChunkManager,
|
||||
ChunkStreamingSystem,
|
||||
ChunkLoaderComponent,
|
||||
StreamingAnchorComponent
|
||||
} from '@esengine/world-streaming';
|
||||
|
||||
// 创建并配置区块管理器
|
||||
const chunkManager = new ChunkManager(512);
|
||||
chunkManager.setScene(scene);
|
||||
chunkManager.setDataProvider(myProvider);
|
||||
|
||||
// 创建流式系统
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
streamingSystem.setChunkManager(chunkManager);
|
||||
scene.addSystem(streamingSystem);
|
||||
|
||||
// 创建加载器实体
|
||||
const loaderEntity = scene.createEntity('ChunkLoader');
|
||||
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
|
||||
loader.chunkSize = 512;
|
||||
loader.loadRadius = 2;
|
||||
loader.unloadRadius = 4;
|
||||
```
|
||||
|
||||
## 流式锚点
|
||||
|
||||
`StreamingAnchorComponent` 标记实体为区块加载锚点。区块会在所有锚点周围加载。
|
||||
|
||||
```typescript
|
||||
// 创建玩家作为流式锚点
|
||||
const playerEntity = scene.createEntity('Player');
|
||||
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
|
||||
|
||||
// 每帧更新位置
|
||||
function update() {
|
||||
anchor.x = player.worldX;
|
||||
anchor.y = player.worldY;
|
||||
}
|
||||
```
|
||||
|
||||
### 锚点属性
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `x` | number | 0 | 世界 X 坐标 |
|
||||
| `y` | number | 0 | 世界 Y 坐标 |
|
||||
| `weight` | number | 1.0 | 加载半径倍数 |
|
||||
| `bEnablePrefetch` | boolean | true | 是否启用预加载 |
|
||||
|
||||
### 多锚点
|
||||
|
||||
```typescript
|
||||
// 主玩家 - 完整加载半径
|
||||
const playerAnchor = player.addComponent(new StreamingAnchorComponent());
|
||||
playerAnchor.weight = 1.0;
|
||||
|
||||
// 相机预览 - 较小半径
|
||||
const cameraAnchor = camera.addComponent(new StreamingAnchorComponent());
|
||||
cameraAnchor.weight = 0.5; // 加载半径减半
|
||||
cameraAnchor.bEnablePrefetch = false;
|
||||
```
|
||||
|
||||
## 加载器配置
|
||||
|
||||
`ChunkLoaderComponent` 配置流式加载行为。
|
||||
|
||||
```typescript
|
||||
const loader = entity.addComponent(new ChunkLoaderComponent());
|
||||
|
||||
// 区块尺寸
|
||||
loader.chunkSize = 512; // 每区块世界单位
|
||||
|
||||
// 加载半径
|
||||
loader.loadRadius = 2; // 锚点周围 2 个区块内加载
|
||||
loader.unloadRadius = 4; // 超过 4 个区块卸载
|
||||
|
||||
// 性能调优
|
||||
loader.maxLoadsPerFrame = 2; // 每帧最大异步加载数
|
||||
loader.maxUnloadsPerFrame = 1; // 每帧最大卸载数
|
||||
loader.unloadDelay = 3000; // 卸载前延迟(毫秒)
|
||||
|
||||
// 预加载
|
||||
loader.bEnablePrefetch = true; // 启用移动方向预加载
|
||||
loader.prefetchRadius = 1; // 预加载额外区块数
|
||||
```
|
||||
|
||||
### 坐标辅助方法
|
||||
|
||||
```typescript
|
||||
// 世界坐标转区块坐标
|
||||
const coord = loader.worldToChunk(1500, 2300);
|
||||
|
||||
// 获取区块边界
|
||||
const bounds = loader.getChunkBounds(coord);
|
||||
```
|
||||
|
||||
## 预加载系统
|
||||
|
||||
启用后,系统会沿移动方向预加载区块:
|
||||
|
||||
```
|
||||
移动方向 →
|
||||
|
||||
[ ][ ][ ] [ ][P][P] P = 预加载
|
||||
[L][L][L] → [L][L][L] L = 已加载
|
||||
[ ][ ][ ] [ ][ ][ ]
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 启用预加载
|
||||
loader.bEnablePrefetch = true;
|
||||
loader.prefetchRadius = 2; // 向前预加载 2 个区块
|
||||
|
||||
// 单独控制锚点的预加载
|
||||
anchor.bEnablePrefetch = true; // 主玩家启用
|
||||
cameraAnchor.bEnablePrefetch = false; // 相机禁用
|
||||
```
|
||||
|
||||
## 系统处理流程
|
||||
|
||||
系统每帧运行:
|
||||
|
||||
1. 更新锚点速度
|
||||
2. 请求加载范围内的区块
|
||||
3. 取消已回到范围内的区块卸载
|
||||
4. 请求卸载超出范围的区块
|
||||
5. 处理加载/卸载队列
|
||||
|
||||
```typescript
|
||||
// 从系统访问区块管理器
|
||||
const system = scene.getSystem(ChunkStreamingSystem);
|
||||
const manager = system?.chunkManager;
|
||||
|
||||
if (manager) {
|
||||
console.log('已加载:', manager.loadedChunkCount);
|
||||
}
|
||||
```
|
||||
|
||||
## 基于优先级的加载
|
||||
|
||||
区块按距离分配加载优先级:
|
||||
|
||||
| 距离 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 0 | Immediate | 玩家当前区块 |
|
||||
| 1 | High | 相邻区块 |
|
||||
| 2-4 | Normal | 附近区块 |
|
||||
| 5+ | Low | 远处区块 |
|
||||
| 预加载 | Prefetch | 移动方向 |
|
||||
|
||||
## 事件
|
||||
|
||||
```typescript
|
||||
chunkManager.setEvents({
|
||||
onChunkLoaded: (coord, entities) => {
|
||||
// 区块就绪 - 生成 NPC,启用碰撞
|
||||
for (const entity of entities) {
|
||||
entity.getComponent(ColliderComponent)?.enable();
|
||||
}
|
||||
},
|
||||
onChunkUnloaded: (coord) => {
|
||||
// 清理 - 保存状态,释放资源
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/network",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.1",
|
||||
"description": "Network synchronization for multiplayer games",
|
||||
"esengine": {
|
||||
"plugin": true,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @esengine/pathfinding
|
||||
|
||||
## 1.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#376](https://github.com/esengine/esengine/pull/376) [`0662b07`](https://github.com/esengine/esengine/commit/0662b074454906ad7c0264fe1d3a241f13730ba1) Thanks [@esengine](https://github.com/esengine)! - fix: update pathfinding package to resolve npm version conflict
|
||||
|
||||
## 1.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/pathfinding",
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.0",
|
||||
"description": "寻路系统 | Pathfinding System - A*, Grid, NavMesh",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
55
packages/framework/server/CHANGELOG.md
Normal file
55
packages/framework/server/CHANGELOG.md
Normal 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
|
||||
21
packages/framework/server/module.json
Normal file
21
packages/framework/server/module.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "server",
|
||||
"name": "@esengine/server",
|
||||
"globalKey": "server",
|
||||
"displayName": "Game Server",
|
||||
"description": "Game server framework with file-based routing | 基于文件路由的游戏服务器框架",
|
||||
"version": "1.0.0",
|
||||
"category": "Network",
|
||||
"icon": "Server",
|
||||
"tags": ["server", "rpc", "websocket", "network"],
|
||||
"isCore": false,
|
||||
"defaultEnabled": false,
|
||||
"isEngineModule": true,
|
||||
"canContainContent": false,
|
||||
"platforms": ["nodejs"],
|
||||
"dependencies": ["rpc"],
|
||||
"exports": {
|
||||
"functions": ["createServer", "defineApi", "defineMsg"]
|
||||
},
|
||||
"outputPath": "dist/index.js"
|
||||
}
|
||||
50
packages/framework/server/package.json
Normal file
50
packages/framework/server/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@esengine/server",
|
||||
"version": "1.1.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"
|
||||
]
|
||||
}
|
||||
253
packages/framework/server/src/core/server.ts
Normal file
253
packages/framework/server/src/core/server.ts
Normal 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, data: payload } = data as { type: string; data: unknown }
|
||||
roomManager.handleMessage(conn.id, type, payload)
|
||||
}
|
||||
|
||||
// 文件路由消息
|
||||
for (const [name, handler] of Object.entries(msgMap)) {
|
||||
msgHandlersObj[name] = async (data, conn) => {
|
||||
const ctx: MsgContext = {
|
||||
conn: conn as ServerConnection,
|
||||
server: gameServer,
|
||||
}
|
||||
await handler.definition.handler(data, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
rpcServer = serve(protocol, {
|
||||
port: opts.port,
|
||||
createConnData: () => ({}),
|
||||
onStart: (p) => {
|
||||
console.log(`[Server] Started on ws://localhost:${p}`)
|
||||
opts.onStart?.(p)
|
||||
},
|
||||
onConnect: async (conn) => {
|
||||
await config.onConnect?.(conn as ServerConnection)
|
||||
},
|
||||
onDisconnect: async (conn) => {
|
||||
// 玩家断线时自动离开房间
|
||||
await roomManager?.leave(conn.id, 'disconnected')
|
||||
await config.onDisconnect?.(conn as ServerConnection)
|
||||
},
|
||||
api: apiHandlersObj as any,
|
||||
msg: msgHandlersObj as any,
|
||||
})
|
||||
|
||||
await rpcServer.start()
|
||||
|
||||
// 启动 tick 循环
|
||||
if (opts.tickRate > 0) {
|
||||
tickInterval = setInterval(() => {
|
||||
currentTick++
|
||||
}, 1000 / opts.tickRate)
|
||||
}
|
||||
},
|
||||
|
||||
async stop() {
|
||||
if (tickInterval) {
|
||||
clearInterval(tickInterval)
|
||||
tickInterval = null
|
||||
}
|
||||
if (rpcServer) {
|
||||
await rpcServer.stop()
|
||||
rpcServer = null
|
||||
}
|
||||
},
|
||||
|
||||
broadcast(name, data) {
|
||||
rpcServer?.broadcast(name as any, data as any)
|
||||
},
|
||||
|
||||
send(conn, name, data) {
|
||||
rpcServer?.send(conn as any, name as any, data as any)
|
||||
},
|
||||
}
|
||||
|
||||
return gameServer as GameServer
|
||||
}
|
||||
51
packages/framework/server/src/helpers/define.ts
Normal file
51
packages/framework/server/src/helpers/define.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @zh API 和消息定义助手
|
||||
* @en API and message definition helpers
|
||||
*/
|
||||
|
||||
import type { ApiDefinition, MsgDefinition } from '../types/index.js'
|
||||
|
||||
/**
|
||||
* @zh 定义 API 处理器
|
||||
* @en Define API handler
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // src/api/join.ts
|
||||
* import { defineApi } from '@esengine/server'
|
||||
*
|
||||
* export default defineApi<ReqJoin, ResJoin>({
|
||||
* handler(req, ctx) {
|
||||
* ctx.conn.data.playerId = generateId()
|
||||
* return { playerId: ctx.conn.data.playerId }
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function defineApi<TReq, TRes, TData = Record<string, unknown>>(
|
||||
definition: ApiDefinition<TReq, TRes, TData>
|
||||
): ApiDefinition<TReq, TRes, TData> {
|
||||
return definition
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 定义消息处理器
|
||||
* @en Define message handler
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // src/msg/input.ts
|
||||
* import { defineMsg } from '@esengine/server'
|
||||
*
|
||||
* export default defineMsg<MsgInput>({
|
||||
* handler(msg, ctx) {
|
||||
* console.log('Input from', ctx.conn.id, msg)
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function defineMsg<TMsg, TData = Record<string, unknown>>(
|
||||
definition: MsgDefinition<TMsg, TData>
|
||||
): MsgDefinition<TMsg, TData> {
|
||||
return definition
|
||||
}
|
||||
52
packages/framework/server/src/index.ts
Normal file
52
packages/framework/server/src/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @zh ESEngine 游戏服务器框架
|
||||
* @en ESEngine Game Server Framework
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createServer, Room, onMessage } from '@esengine/server'
|
||||
*
|
||||
* class GameRoom extends Room {
|
||||
* maxPlayers = 4
|
||||
* tickRate = 20
|
||||
*
|
||||
* onJoin(player) {
|
||||
* this.broadcast('Joined', { id: player.id })
|
||||
* }
|
||||
*
|
||||
* @onMessage('Move')
|
||||
* handleMove(data, player) {
|
||||
* // handle move
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* const server = await createServer({ port: 3000 })
|
||||
* server.define('game', GameRoom)
|
||||
* await server.start()
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Core
|
||||
export { createServer } from './core/server.js'
|
||||
|
||||
// Helpers
|
||||
export { defineApi, defineMsg } from './helpers/define.js'
|
||||
|
||||
// Room System
|
||||
export { Room, type RoomOptions } from './room/Room.js'
|
||||
export { Player, type IPlayer } from './room/Player.js'
|
||||
export { onMessage } from './room/decorators.js'
|
||||
|
||||
// Types
|
||||
export type {
|
||||
ServerConfig,
|
||||
ServerConnection,
|
||||
GameServer,
|
||||
ApiContext,
|
||||
MsgContext,
|
||||
ApiDefinition,
|
||||
MsgDefinition,
|
||||
} from './types/index.js'
|
||||
|
||||
// Re-export useful types from @esengine/rpc
|
||||
export { RpcError, ErrorCode } from '@esengine/rpc'
|
||||
72
packages/framework/server/src/room/Player.ts
Normal file
72
packages/framework/server/src/room/Player.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @zh 玩家类
|
||||
* @en Player class
|
||||
*/
|
||||
|
||||
import type { Connection } from '@esengine/rpc'
|
||||
|
||||
/**
|
||||
* @zh 玩家接口
|
||||
* @en Player interface
|
||||
*/
|
||||
export interface IPlayer<TData = Record<string, unknown>> {
|
||||
readonly id: string
|
||||
readonly roomId: string
|
||||
data: TData
|
||||
send<T>(type: string, data: T): void
|
||||
leave(reason?: string): void
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 玩家实现
|
||||
* @en Player implementation
|
||||
*/
|
||||
export class Player<TData = Record<string, unknown>> implements IPlayer<TData> {
|
||||
readonly id: string
|
||||
readonly roomId: string
|
||||
data: TData
|
||||
|
||||
private _conn: Connection<any>
|
||||
private _sendFn: (conn: Connection<any>, type: string, data: unknown) => void
|
||||
private _leaveFn: (player: Player<TData>, reason?: string) => void
|
||||
|
||||
constructor(options: {
|
||||
id: string
|
||||
roomId: string
|
||||
conn: Connection<any>
|
||||
sendFn: (conn: Connection<any>, type: string, data: unknown) => void
|
||||
leaveFn: (player: Player<TData>, reason?: string) => void
|
||||
initialData?: TData
|
||||
}) {
|
||||
this.id = options.id
|
||||
this.roomId = options.roomId
|
||||
this._conn = options.conn
|
||||
this._sendFn = options.sendFn
|
||||
this._leaveFn = options.leaveFn
|
||||
this.data = options.initialData ?? ({} as TData)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取底层连接
|
||||
* @en Get underlying connection
|
||||
*/
|
||||
get connection(): Connection<any> {
|
||||
return this._conn
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 发送消息给玩家
|
||||
* @en Send message to player
|
||||
*/
|
||||
send<T>(type: string, data: T): void {
|
||||
this._sendFn(this._conn, type, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 让玩家离开房间
|
||||
* @en Make player leave the room
|
||||
*/
|
||||
leave(reason?: string): void {
|
||||
this._leaveFn(this, reason)
|
||||
}
|
||||
}
|
||||
383
packages/framework/server/src/room/Room.ts
Normal file
383
packages/framework/server/src/room/Room.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @zh 房间基类
|
||||
* @en Room base class
|
||||
*/
|
||||
|
||||
import { Player } from './Player.js'
|
||||
|
||||
/**
|
||||
* @zh 房间配置
|
||||
* @en Room options
|
||||
*/
|
||||
export interface RoomOptions {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 消息处理器元数据
|
||||
* @en Message handler metadata
|
||||
*/
|
||||
interface MessageHandlerMeta {
|
||||
type: string
|
||||
method: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 消息处理器存储 key
|
||||
* @en Message handler storage key
|
||||
*/
|
||||
const MESSAGE_HANDLERS = Symbol('messageHandlers')
|
||||
|
||||
/**
|
||||
* @zh 房间基类
|
||||
* @en Room base class
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class GameRoom extends Room {
|
||||
* maxPlayers = 4
|
||||
* tickRate = 20
|
||||
*
|
||||
* onJoin(player: Player) {
|
||||
* this.broadcast('Joined', { id: player.id })
|
||||
* }
|
||||
*
|
||||
* @onMessage('Move')
|
||||
* handleMove(data: { x: number, y: number }, player: Player) {
|
||||
* // handle move
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export abstract class Room<TState = any, TPlayerData = Record<string, unknown>> {
|
||||
// ========================================================================
|
||||
// 配置 | Configuration
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @zh 最大玩家数
|
||||
* @en Maximum players
|
||||
*/
|
||||
maxPlayers = 16
|
||||
|
||||
/**
|
||||
* @zh Tick 速率(每秒),0 = 不自动 tick
|
||||
* @en Tick rate (per second), 0 = no auto tick
|
||||
*/
|
||||
tickRate = 0
|
||||
|
||||
/**
|
||||
* @zh 空房间自动销毁
|
||||
* @en Auto dispose when empty
|
||||
*/
|
||||
autoDispose = true
|
||||
|
||||
// ========================================================================
|
||||
// 状态 | State
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @zh 房间状态
|
||||
* @en Room state
|
||||
*/
|
||||
state: TState = {} as TState
|
||||
|
||||
// ========================================================================
|
||||
// 内部属性 | Internal properties
|
||||
// ========================================================================
|
||||
|
||||
private _id: string = ''
|
||||
private _players: Map<string, Player<TPlayerData>> = new Map()
|
||||
private _locked = false
|
||||
private _disposed = false
|
||||
private _tickInterval: ReturnType<typeof setInterval> | null = null
|
||||
private _lastTickTime = 0
|
||||
private _broadcastFn: ((type: string, data: unknown) => void) | null = null
|
||||
private _sendFn: ((conn: any, type: string, data: unknown) => void) | null = null
|
||||
private _disposeFn: (() => void) | null = null
|
||||
|
||||
// ========================================================================
|
||||
// 只读属性 | Readonly properties
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @zh 房间 ID
|
||||
* @en Room ID
|
||||
*/
|
||||
get id(): string {
|
||||
return this._id
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 所有玩家
|
||||
* @en All players
|
||||
*/
|
||||
get players(): ReadonlyArray<Player<TPlayerData>> {
|
||||
return Array.from(this._players.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 玩家数量
|
||||
* @en Player count
|
||||
*/
|
||||
get playerCount(): number {
|
||||
return this._players.size
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否已满
|
||||
* @en Is full
|
||||
*/
|
||||
get isFull(): boolean {
|
||||
return this._players.size >= this.maxPlayers
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否已锁定
|
||||
* @en Is locked
|
||||
*/
|
||||
get isLocked(): boolean {
|
||||
return this._locked
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否已销毁
|
||||
* @en Is disposed
|
||||
*/
|
||||
get isDisposed(): boolean {
|
||||
return this._disposed
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 生命周期 | Lifecycle
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @zh 房间创建时调用
|
||||
* @en Called when room is created
|
||||
*/
|
||||
onCreate(options?: RoomOptions): void | Promise<void> {}
|
||||
|
||||
/**
|
||||
* @zh 玩家加入时调用
|
||||
* @en Called when player joins
|
||||
*/
|
||||
onJoin(player: Player<TPlayerData>): void | Promise<void> {}
|
||||
|
||||
/**
|
||||
* @zh 玩家离开时调用
|
||||
* @en Called when player leaves
|
||||
*/
|
||||
onLeave(player: Player<TPlayerData>, reason?: string): void | Promise<void> {}
|
||||
|
||||
/**
|
||||
* @zh 游戏循环
|
||||
* @en Game tick
|
||||
*/
|
||||
onTick(dt: number): void {}
|
||||
|
||||
/**
|
||||
* @zh 房间销毁时调用
|
||||
* @en Called when room is disposed
|
||||
*/
|
||||
onDispose(): void | Promise<void> {}
|
||||
|
||||
// ========================================================================
|
||||
// 公共方法 | Public methods
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @zh 广播消息给所有玩家
|
||||
* @en Broadcast message to all players
|
||||
*/
|
||||
broadcast<T>(type: string, data: T): void {
|
||||
for (const player of this._players.values()) {
|
||||
player.send(type, data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 广播消息给除指定玩家外的所有玩家
|
||||
* @en Broadcast message to all players except one
|
||||
*/
|
||||
broadcastExcept<T>(except: Player<TPlayerData>, type: string, data: T): void {
|
||||
for (const player of this._players.values()) {
|
||||
if (player.id !== except.id) {
|
||||
player.send(type, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取玩家
|
||||
* @en Get player by id
|
||||
*/
|
||||
getPlayer(id: string): Player<TPlayerData> | undefined {
|
||||
return this._players.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 踢出玩家
|
||||
* @en Kick player
|
||||
*/
|
||||
kick(player: Player<TPlayerData>, reason?: string): void {
|
||||
player.leave(reason ?? 'kicked')
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 锁定房间
|
||||
* @en Lock room
|
||||
*/
|
||||
lock(): void {
|
||||
this._locked = true
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解锁房间
|
||||
* @en Unlock room
|
||||
*/
|
||||
unlock(): void {
|
||||
this._locked = false
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 手动销毁房间
|
||||
* @en Manually dispose room
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this._disposed) return
|
||||
this._disposed = true
|
||||
|
||||
this._stopTick()
|
||||
|
||||
for (const player of this._players.values()) {
|
||||
player.leave('room_disposed')
|
||||
}
|
||||
this._players.clear()
|
||||
|
||||
this.onDispose()
|
||||
this._disposeFn?.()
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 内部方法 | Internal methods
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
_init(options: {
|
||||
id: string
|
||||
sendFn: (conn: any, type: string, data: unknown) => void
|
||||
broadcastFn: (type: string, data: unknown) => void
|
||||
disposeFn: () => void
|
||||
}): void {
|
||||
this._id = options.id
|
||||
this._sendFn = options.sendFn
|
||||
this._broadcastFn = options.broadcastFn
|
||||
this._disposeFn = options.disposeFn
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
async _create(options?: RoomOptions): Promise<void> {
|
||||
await this.onCreate(options)
|
||||
this._startTick()
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
async _addPlayer(id: string, conn: any): Promise<Player<TPlayerData> | null> {
|
||||
if (this._locked || this.isFull || this._disposed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const player = new Player<TPlayerData>({
|
||||
id,
|
||||
roomId: this._id,
|
||||
conn,
|
||||
sendFn: this._sendFn!,
|
||||
leaveFn: (p, reason) => this._removePlayer(p.id, reason),
|
||||
})
|
||||
|
||||
this._players.set(id, player)
|
||||
await this.onJoin(player)
|
||||
|
||||
return player
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
async _removePlayer(id: string, reason?: string): Promise<void> {
|
||||
const player = this._players.get(id)
|
||||
if (!player) return
|
||||
|
||||
this._players.delete(id)
|
||||
await this.onLeave(player, reason)
|
||||
|
||||
if (this.autoDispose && this._players.size === 0) {
|
||||
this.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
_handleMessage(type: string, data: unknown, playerId: string): void {
|
||||
const player = this._players.get(playerId)
|
||||
if (!player) return
|
||||
|
||||
const handlers = (this.constructor as any)[MESSAGE_HANDLERS] as MessageHandlerMeta[] | undefined
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
if (handler.type === type) {
|
||||
const method = (this as any)[handler.method]
|
||||
if (typeof method === 'function') {
|
||||
method.call(this, data, player)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _startTick(): void {
|
||||
if (this.tickRate <= 0) return
|
||||
|
||||
this._lastTickTime = performance.now()
|
||||
this._tickInterval = setInterval(() => {
|
||||
const now = performance.now()
|
||||
const dt = (now - this._lastTickTime) / 1000
|
||||
this._lastTickTime = now
|
||||
this.onTick(dt)
|
||||
}, 1000 / this.tickRate)
|
||||
}
|
||||
|
||||
private _stopTick(): void {
|
||||
if (this._tickInterval) {
|
||||
clearInterval(this._tickInterval)
|
||||
this._tickInterval = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取消息处理器元数据
|
||||
* @en Get message handler metadata
|
||||
*/
|
||||
export function getMessageHandlers(target: any): MessageHandlerMeta[] {
|
||||
return target[MESSAGE_HANDLERS] || []
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 注册消息处理器元数据
|
||||
* @en Register message handler metadata
|
||||
*/
|
||||
export function registerMessageHandler(target: any, type: string, method: string): void {
|
||||
if (!target[MESSAGE_HANDLERS]) {
|
||||
target[MESSAGE_HANDLERS] = []
|
||||
}
|
||||
target[MESSAGE_HANDLERS].push({ type, method })
|
||||
}
|
||||
221
packages/framework/server/src/room/RoomManager.ts
Normal file
221
packages/framework/server/src/room/RoomManager.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @zh 房间管理器
|
||||
* @en Room manager
|
||||
*/
|
||||
|
||||
import { Room, type RoomOptions } from './Room.js'
|
||||
import type { Player } from './Player.js'
|
||||
|
||||
/**
|
||||
* @zh 房间类型
|
||||
* @en Room class type
|
||||
*/
|
||||
export type RoomClass<T extends Room = Room> = new () => T
|
||||
|
||||
/**
|
||||
* @zh 房间定义
|
||||
* @en Room definition
|
||||
*/
|
||||
interface RoomDefinition {
|
||||
roomClass: RoomClass
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 房间管理器
|
||||
* @en Room manager
|
||||
*/
|
||||
export class RoomManager {
|
||||
private _definitions: Map<string, RoomDefinition> = new Map()
|
||||
private _rooms: Map<string, Room> = new Map()
|
||||
private _playerToRoom: Map<string, string> = new Map()
|
||||
private _nextRoomId = 1
|
||||
|
||||
private _sendFn: (conn: any, type: string, data: unknown) => void
|
||||
|
||||
constructor(sendFn: (conn: any, type: string, data: unknown) => void) {
|
||||
this._sendFn = sendFn
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 注册房间类型
|
||||
* @en Define room type
|
||||
*/
|
||||
define<T extends Room>(name: string, roomClass: RoomClass<T>): void {
|
||||
this._definitions.set(name, { roomClass })
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建房间
|
||||
* @en Create room
|
||||
*/
|
||||
async create(name: string, options?: RoomOptions): Promise<Room | null> {
|
||||
const def = this._definitions.get(name)
|
||||
if (!def) {
|
||||
console.warn(`[RoomManager] Room type not found: ${name}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const roomId = this._generateRoomId()
|
||||
const room = new def.roomClass()
|
||||
|
||||
room._init({
|
||||
id: roomId,
|
||||
sendFn: this._sendFn,
|
||||
broadcastFn: (type, data) => {
|
||||
for (const player of room.players) {
|
||||
player.send(type, data)
|
||||
}
|
||||
},
|
||||
disposeFn: () => {
|
||||
this._rooms.delete(roomId)
|
||||
},
|
||||
})
|
||||
|
||||
this._rooms.set(roomId, room)
|
||||
await room._create(options)
|
||||
|
||||
console.log(`[Room] Created: ${name} (${roomId})`)
|
||||
return room
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 加入或创建房间
|
||||
* @en Join or create room
|
||||
*/
|
||||
async joinOrCreate(
|
||||
name: string,
|
||||
playerId: string,
|
||||
conn: any,
|
||||
options?: RoomOptions
|
||||
): Promise<{ room: Room; player: Player } | null> {
|
||||
// 查找可加入的房间
|
||||
let room = this._findAvailableRoom(name)
|
||||
|
||||
// 没有则创建
|
||||
if (!room) {
|
||||
room = await this.create(name, options)
|
||||
if (!room) return null
|
||||
}
|
||||
|
||||
// 加入房间
|
||||
const player = await room._addPlayer(playerId, conn)
|
||||
if (!player) return null
|
||||
|
||||
this._playerToRoom.set(playerId, room.id)
|
||||
|
||||
console.log(`[Room] Player ${playerId} joined ${room.id}`)
|
||||
return { room, player }
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 加入指定房间
|
||||
* @en Join specific room
|
||||
*/
|
||||
async joinById(
|
||||
roomId: string,
|
||||
playerId: string,
|
||||
conn: any
|
||||
): Promise<{ room: Room; player: Player } | null> {
|
||||
const room = this._rooms.get(roomId)
|
||||
if (!room) return null
|
||||
|
||||
const player = await room._addPlayer(playerId, conn)
|
||||
if (!player) return null
|
||||
|
||||
this._playerToRoom.set(playerId, room.id)
|
||||
|
||||
console.log(`[Room] Player ${playerId} joined ${room.id}`)
|
||||
return { room, player }
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 玩家离开
|
||||
* @en Player leave
|
||||
*/
|
||||
async leave(playerId: string, reason?: string): Promise<void> {
|
||||
const roomId = this._playerToRoom.get(playerId)
|
||||
if (!roomId) return
|
||||
|
||||
const room = this._rooms.get(roomId)
|
||||
if (room) {
|
||||
await room._removePlayer(playerId, reason)
|
||||
}
|
||||
|
||||
this._playerToRoom.delete(playerId)
|
||||
console.log(`[Room] Player ${playerId} left ${roomId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 处理消息
|
||||
* @en Handle message
|
||||
*/
|
||||
handleMessage(playerId: string, type: string, data: unknown): void {
|
||||
const roomId = this._playerToRoom.get(playerId)
|
||||
if (!roomId) return
|
||||
|
||||
const room = this._rooms.get(roomId)
|
||||
if (room) {
|
||||
room._handleMessage(type, data, playerId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取房间
|
||||
* @en Get room
|
||||
*/
|
||||
getRoom(roomId: string): Room | undefined {
|
||||
return this._rooms.get(roomId)
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取玩家所在房间
|
||||
* @en Get player's room
|
||||
*/
|
||||
getPlayerRoom(playerId: string): Room | undefined {
|
||||
const roomId = this._playerToRoom.get(playerId)
|
||||
return roomId ? this._rooms.get(roomId) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取所有房间
|
||||
* @en Get all rooms
|
||||
*/
|
||||
getRooms(): ReadonlyArray<Room> {
|
||||
return Array.from(this._rooms.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取指定类型的所有房间
|
||||
* @en Get all rooms of a type
|
||||
*/
|
||||
getRoomsByType(name: string): Room[] {
|
||||
const def = this._definitions.get(name)
|
||||
if (!def) return []
|
||||
|
||||
return Array.from(this._rooms.values()).filter(
|
||||
room => room instanceof def.roomClass
|
||||
)
|
||||
}
|
||||
|
||||
private _findAvailableRoom(name: string): Room | undefined {
|
||||
const def = this._definitions.get(name)
|
||||
if (!def) return undefined
|
||||
|
||||
for (const room of this._rooms.values()) {
|
||||
if (
|
||||
room instanceof def.roomClass &&
|
||||
!room.isFull &&
|
||||
!room.isLocked &&
|
||||
!room.isDisposed
|
||||
) {
|
||||
return room
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private _generateRoomId(): string {
|
||||
return `room_${this._nextRoomId++}`
|
||||
}
|
||||
}
|
||||
35
packages/framework/server/src/room/decorators.ts
Normal file
35
packages/framework/server/src/room/decorators.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @zh 房间装饰器
|
||||
* @en Room decorators
|
||||
*/
|
||||
|
||||
import { registerMessageHandler } from './Room.js'
|
||||
|
||||
/**
|
||||
* @zh 消息处理器装饰器
|
||||
* @en Message handler decorator
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class GameRoom extends Room {
|
||||
* @onMessage('Move')
|
||||
* handleMove(data: { x: number, y: number }, player: Player) {
|
||||
* // handle move
|
||||
* }
|
||||
*
|
||||
* @onMessage('Chat')
|
||||
* handleChat(data: { text: string }, player: Player) {
|
||||
* this.broadcast('Chat', { from: player.id, text: data.text })
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function onMessage(type: string): MethodDecorator {
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
_descriptor: PropertyDescriptor
|
||||
) {
|
||||
registerMessageHandler(target.constructor, type, propertyKey as string)
|
||||
}
|
||||
}
|
||||
9
packages/framework/server/src/room/index.ts
Normal file
9
packages/framework/server/src/room/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @zh 房间系统
|
||||
* @en Room system
|
||||
*/
|
||||
|
||||
export { Room, type RoomOptions } from './Room.js'
|
||||
export { Player, type IPlayer } from './Player.js'
|
||||
export { RoomManager, type RoomClass } from './RoomManager.js'
|
||||
export { onMessage } from './decorators.js'
|
||||
112
packages/framework/server/src/router/loader.ts
Normal file
112
packages/framework/server/src/router/loader.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @zh 文件路由加载器
|
||||
* @en File-based router loader
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type { ApiDefinition, MsgDefinition, LoadedApiHandler, LoadedMsgHandler } from '../types/index.js'
|
||||
|
||||
/**
|
||||
* @zh 将文件名转换为 API/消息名称
|
||||
* @en Convert filename to API/message name
|
||||
*
|
||||
* @example
|
||||
* 'join.ts' -> 'Join'
|
||||
* 'spawn-agent.ts' -> 'SpawnAgent'
|
||||
* 'save_blueprint.ts' -> 'SaveBlueprint'
|
||||
*/
|
||||
function fileNameToHandlerName(fileName: string): string {
|
||||
const baseName = fileName.replace(/\.(ts|js|mts|mjs)$/, '')
|
||||
|
||||
return baseName
|
||||
.split(/[-_]/)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 扫描目录获取所有处理器文件
|
||||
* @en Scan directory for all handler files
|
||||
*/
|
||||
function scanDirectory(dir: string): string[] {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const files: string[] = []
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && /\.(ts|js|mts|mjs)$/.test(entry.name)) {
|
||||
// 跳过 index 和下划线开头的文件
|
||||
if (entry.name.startsWith('_') || entry.name.startsWith('index.')) {
|
||||
continue
|
||||
}
|
||||
files.push(path.join(dir, entry.name))
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 加载 API 处理器
|
||||
* @en Load API handlers
|
||||
*/
|
||||
export async function loadApiHandlers(apiDir: string): Promise<LoadedApiHandler[]> {
|
||||
const files = scanDirectory(apiDir)
|
||||
const handlers: LoadedApiHandler[] = []
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const fileUrl = pathToFileURL(filePath).href
|
||||
const module = await import(fileUrl)
|
||||
const definition = module.default as ApiDefinition<unknown, unknown, unknown>
|
||||
|
||||
if (definition && typeof definition.handler === 'function') {
|
||||
const name = fileNameToHandlerName(path.basename(filePath))
|
||||
handlers.push({
|
||||
name,
|
||||
path: filePath,
|
||||
definition,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[Server] Failed to load API handler: ${filePath}`, err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlers
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 加载消息处理器
|
||||
* @en Load message handlers
|
||||
*/
|
||||
export async function loadMsgHandlers(msgDir: string): Promise<LoadedMsgHandler[]> {
|
||||
const files = scanDirectory(msgDir)
|
||||
const handlers: LoadedMsgHandler[] = []
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const fileUrl = pathToFileURL(filePath).href
|
||||
const module = await import(fileUrl)
|
||||
const definition = module.default as MsgDefinition<unknown, unknown>
|
||||
|
||||
if (definition && typeof definition.handler === 'function') {
|
||||
const name = fileNameToHandlerName(path.basename(filePath))
|
||||
handlers.push({
|
||||
name,
|
||||
path: filePath,
|
||||
definition,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[Server] Failed to load msg handler: ${filePath}`, err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlers
|
||||
}
|
||||
234
packages/framework/server/src/types/index.ts
Normal file
234
packages/framework/server/src/types/index.ts
Normal 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>
|
||||
}
|
||||
9
packages/framework/server/tsconfig.json
Normal file
9
packages/framework/server/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
11
packages/framework/server/tsup.config.ts
Normal file
11
packages/framework/server/tsup.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
external: ['ws', '@esengine/rpc'],
|
||||
treeshake: true,
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/world-streaming",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "World streaming and chunk management system for open world games",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
@@ -12,13 +12,18 @@
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"module.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*"
|
||||
"@esengine/ecs-framework-math": "workspace:*",
|
||||
"@esengine/spatial": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.0.1",
|
||||
@@ -26,15 +31,25 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*"
|
||||
"@esengine/ecs-framework-math": "workspace:*",
|
||||
"@esengine/spatial": "workspace:*"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"streaming",
|
||||
"chunk",
|
||||
"open-world"
|
||||
"open-world",
|
||||
"esengine"
|
||||
],
|
||||
"author": "ESEngine",
|
||||
"license": "MIT",
|
||||
"private": true
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/esengine/esengine.git",
|
||||
"directory": "packages/framework/world-streaming"
|
||||
}
|
||||
}
|
||||
100
packages/framework/world-streaming/src/WorldStreamingModule.ts
Normal file
100
packages/framework/world-streaming/src/WorldStreamingModule.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { IScene, ServiceContainer, IComponentRegistry } from '@esengine/ecs-framework';
|
||||
import { ChunkComponent } from './components/ChunkComponent';
|
||||
import { StreamingAnchorComponent } from './components/StreamingAnchorComponent';
|
||||
import { ChunkLoaderComponent } from './components/ChunkLoaderComponent';
|
||||
import { ChunkStreamingSystem } from './systems/ChunkStreamingSystem';
|
||||
import { ChunkCullingSystem } from './systems/ChunkCullingSystem';
|
||||
import { ChunkManager } from './services/ChunkManager';
|
||||
|
||||
/**
|
||||
* 世界流式加载配置
|
||||
*
|
||||
* Configuration for world streaming setup.
|
||||
*/
|
||||
export interface IWorldStreamingSetupOptions {
|
||||
/**
|
||||
* 区块大小(世界单位)
|
||||
*
|
||||
* Chunk size in world units.
|
||||
*/
|
||||
chunkSize?: number;
|
||||
|
||||
/**
|
||||
* 是否添加 Culling 系统
|
||||
*
|
||||
* Whether to add the culling system.
|
||||
*/
|
||||
bEnableCulling?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 世界流式加载模块
|
||||
*
|
||||
* Helper class for setting up world streaming functionality.
|
||||
*
|
||||
* 提供世界流式加载功能的帮助类。
|
||||
*/
|
||||
export class WorldStreamingModule {
|
||||
private _chunkManager: ChunkManager | null = null;
|
||||
|
||||
get chunkManager(): ChunkManager | null {
|
||||
return this._chunkManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册组件到注册表
|
||||
*
|
||||
* Register streaming components to registry.
|
||||
*/
|
||||
registerComponents(registry: IComponentRegistry): void {
|
||||
registry.register(ChunkComponent);
|
||||
registry.register(StreamingAnchorComponent);
|
||||
registry.register(ChunkLoaderComponent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册服务到容器
|
||||
*
|
||||
* Register streaming services to container.
|
||||
*/
|
||||
registerServices(services: ServiceContainer, chunkSize?: number): void {
|
||||
this._chunkManager = new ChunkManager(chunkSize);
|
||||
services.registerInstance(ChunkManager, this._chunkManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并添加系统到场景
|
||||
*
|
||||
* Create and add streaming systems to scene.
|
||||
*/
|
||||
createSystems(scene: IScene, options?: IWorldStreamingSetupOptions): void {
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
if (this._chunkManager) {
|
||||
streamingSystem.setChunkManager(this._chunkManager);
|
||||
}
|
||||
scene.addSystem(streamingSystem);
|
||||
|
||||
if (options?.bEnableCulling !== false) {
|
||||
scene.addSystem(new ChunkCullingSystem());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键设置流式加载
|
||||
*
|
||||
* Setup world streaming in one call.
|
||||
*/
|
||||
setup(
|
||||
scene: IScene,
|
||||
services: ServiceContainer,
|
||||
registry: IComponentRegistry,
|
||||
options?: IWorldStreamingSetupOptions
|
||||
): ChunkManager {
|
||||
this.registerComponents(registry);
|
||||
this.registerServices(services, options?.chunkSize);
|
||||
this.createSystems(scene, options);
|
||||
return this._chunkManager!;
|
||||
}
|
||||
}
|
||||
|
||||
export const worldStreamingModule = new WorldStreamingModule();
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
|
||||
import type { IPositionable } from '@esengine/spatial';
|
||||
import type { IVector2 } from '@esengine/ecs-framework-math';
|
||||
|
||||
/**
|
||||
* 流式锚点组件
|
||||
@@ -8,10 +10,39 @@ import { Component, ECSComponent, Serializable, Serialize, Property } from '@ese
|
||||
*
|
||||
* 标记实体作为流式加载锚点。通常挂载在玩家或摄像机实体上,
|
||||
* 系统会根据锚点位置加载/卸载周围区块。
|
||||
*
|
||||
* 用户需要在每帧更新此组件的 x/y 位置。
|
||||
* User must update the x/y position each frame.
|
||||
*/
|
||||
@ECSComponent('StreamingAnchor')
|
||||
@Serializable({ version: 1, typeId: 'StreamingAnchor' })
|
||||
export class StreamingAnchorComponent extends Component {
|
||||
export class StreamingAnchorComponent extends Component implements IPositionable {
|
||||
/**
|
||||
* 当前 X 位置
|
||||
*
|
||||
* Current X position in world units.
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'X' })
|
||||
x: number = 0;
|
||||
|
||||
/**
|
||||
* 当前 Y 位置
|
||||
*
|
||||
* Current Y position in world units.
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Y' })
|
||||
y: number = 0;
|
||||
|
||||
/**
|
||||
* 获取位置 (IPositionable 接口)
|
||||
*
|
||||
* Get position (IPositionable interface).
|
||||
*/
|
||||
get position(): IVector2 {
|
||||
return { x: this.x, y: this.y };
|
||||
}
|
||||
/**
|
||||
* 锚点权重
|
||||
*
|
||||
@@ -51,3 +51,4 @@ export type {
|
||||
|
||||
// Module
|
||||
export { WorldStreamingModule, worldStreamingModule } from './WorldStreamingModule';
|
||||
export type { IWorldStreamingSetupOptions } from './WorldStreamingModule';
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Entity, IScene, IService } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import type { IChunkCoord, IChunkData, IChunkInfo, IChunkLoadRequest, IChunkBounds } from '../types';
|
||||
import { EChunkState, EChunkPriority } from '../types';
|
||||
import { SpatialHashGrid } from './SpatialHashGrid';
|
||||
@@ -286,11 +285,6 @@ export class ChunkManager implements IService {
|
||||
chunkComponent.initialize(coord, bounds);
|
||||
chunkComponent.setState(EChunkState.Loaded);
|
||||
|
||||
const transform = chunkEntity.getComponent(TransformComponent);
|
||||
if (transform) {
|
||||
transform.setPosition(bounds.minX, bounds.minY);
|
||||
}
|
||||
|
||||
return [chunkEntity];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Entity, IScene } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import type { IPositionable } from '@esengine/spatial';
|
||||
import type { IChunkCoord, IChunkData, ISerializedEntity, IChunkBounds } from '../types';
|
||||
|
||||
/**
|
||||
@@ -30,14 +30,14 @@ export class ChunkSerializer implements IChunkSerializer {
|
||||
const serializedEntities: ISerializedEntity[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
if (!transform) continue;
|
||||
const positionable = this.getPositionable(entity);
|
||||
if (!positionable) continue;
|
||||
|
||||
const serialized: ISerializedEntity = {
|
||||
name: entity.name,
|
||||
localPosition: {
|
||||
x: transform.position.x - bounds.minX,
|
||||
y: transform.position.y - bounds.minY
|
||||
x: positionable.position.x - bounds.minX,
|
||||
y: positionable.position.y - bounds.minY
|
||||
},
|
||||
components: this.serializeComponents(entity)
|
||||
};
|
||||
@@ -52,10 +52,26 @@ export class ChunkSerializer implements IChunkSerializer {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实体的可定位组件
|
||||
*
|
||||
* Get positionable component from entity.
|
||||
* Override to use custom position component.
|
||||
*/
|
||||
protected getPositionable(entity: Entity): IPositionable | null {
|
||||
for (const component of entity.components) {
|
||||
if ('position' in component && typeof (component as IPositionable).position === 'object') {
|
||||
return component as IPositionable;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反序列化区块
|
||||
*
|
||||
* Deserialize chunk data and create entities.
|
||||
* Override setEntityPosition to set position on your custom component.
|
||||
*/
|
||||
deserialize(data: IChunkData, scene: IScene): Entity[] {
|
||||
const entities: Entity[] = [];
|
||||
@@ -64,13 +80,9 @@ export class ChunkSerializer implements IChunkSerializer {
|
||||
for (const entityData of data.entities) {
|
||||
const entity = scene.createEntity(entityData.name);
|
||||
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
if (transform) {
|
||||
transform.setPosition(
|
||||
bounds.minX + entityData.localPosition.x,
|
||||
bounds.minY + entityData.localPosition.y
|
||||
);
|
||||
}
|
||||
const worldX = bounds.minX + entityData.localPosition.x;
|
||||
const worldY = bounds.minY + entityData.localPosition.y;
|
||||
this.setEntityPosition(entity, worldX, worldY);
|
||||
|
||||
this.deserializeComponents(entity, entityData.components);
|
||||
entities.push(entity);
|
||||
@@ -79,6 +91,16 @@ export class ChunkSerializer implements IChunkSerializer {
|
||||
return entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置实体位置
|
||||
*
|
||||
* Set entity position after deserialization.
|
||||
* Override to use your custom position component.
|
||||
*/
|
||||
protected setEntityPosition(_entity: Entity, _x: number, _y: number): void {
|
||||
// Override in subclass to set position on your position component
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化实体组件
|
||||
*
|
||||
@@ -113,7 +135,7 @@ export class ChunkSerializer implements IChunkSerializer {
|
||||
* Check if component should be serialized.
|
||||
*/
|
||||
protected shouldSerializeComponent(componentName: string): boolean {
|
||||
const excludeList = ['TransformComponent', 'ChunkComponent', 'StreamingAnchorComponent'];
|
||||
const excludeList = ['ChunkComponent', 'StreamingAnchorComponent'];
|
||||
return !excludeList.includes(componentName);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { EntitySystem, Matcher, Time, ECSSystem } from '@esengine/ecs-framework';
|
||||
import type { Entity, Scene } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { StreamingAnchorComponent } from '../components/StreamingAnchorComponent';
|
||||
import { ChunkLoaderComponent } from '../components/ChunkLoaderComponent';
|
||||
import { ChunkManager } from '../services/ChunkManager';
|
||||
@@ -21,7 +20,7 @@ export class ChunkStreamingSystem extends EntitySystem {
|
||||
private _lastAnchorChunks: Map<Entity, IChunkCoord> = new Map();
|
||||
|
||||
constructor() {
|
||||
super(Matcher.all(StreamingAnchorComponent, TransformComponent));
|
||||
super(Matcher.all(StreamingAnchorComponent));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,12 +82,10 @@ export class ChunkStreamingSystem extends EntitySystem {
|
||||
private updateAnchors(entities: readonly Entity[], deltaTime: number): void {
|
||||
for (const entity of entities) {
|
||||
const anchor = entity.getComponent(StreamingAnchorComponent);
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
if (!anchor) continue;
|
||||
|
||||
if (!anchor || !transform) continue;
|
||||
|
||||
const currentX = transform.position.x;
|
||||
const currentY = transform.position.y;
|
||||
const currentX = anchor.x;
|
||||
const currentY = anchor.y;
|
||||
|
||||
if (deltaTime > 0) {
|
||||
anchor.velocityX = (currentX - anchor.previousX) / deltaTime;
|
||||
@@ -111,10 +108,10 @@ export class ChunkStreamingSystem extends EntitySystem {
|
||||
const centerCoords: IChunkCoord[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
if (!transform) continue;
|
||||
const anchor = entity.getComponent(StreamingAnchorComponent);
|
||||
if (!anchor) continue;
|
||||
|
||||
const coord = loader.worldToChunk(transform.position.x, transform.position.y);
|
||||
const coord = loader.worldToChunk(anchor.x, anchor.y);
|
||||
centerCoords.push(coord);
|
||||
|
||||
const lastCoord = this._lastAnchorChunks.get(entity);
|
||||
@@ -1,17 +0,0 @@
|
||||
# @esengine/network-server
|
||||
|
||||
## 1.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#354](https://github.com/esengine/esengine/pull/354) [`1e240e8`](https://github.com/esengine/esengine/commit/1e240e86f2f75672c3609c9d86238a9ec08ebb4e) Thanks [@esengine](https://github.com/esengine)! - feat(cli): 增强 Node.js 服务端适配器
|
||||
|
||||
**@esengine/cli:**
|
||||
- 添加 @esengine/network-server 依赖支持
|
||||
- 生成完整的 ECS 游戏服务器项目结构
|
||||
- 组件使用 @ECSComponent 装饰器注册
|
||||
- tsconfig 启用 experimentalDecorators
|
||||
|
||||
**@esengine/network-server:**
|
||||
- 支持 ESM/CJS 双格式导出
|
||||
- 添加 ws@8.18.0 解决 Node.js 24 兼容性问题
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"name": "@esengine/network-server",
|
||||
"version": "1.0.2",
|
||||
"description": "TSRPC-based network server for ESEngine",
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsx src/main.ts",
|
||||
"start": "node dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/network-protocols": "workspace:*",
|
||||
"tsrpc": "^3.4.15",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.19.0"
|
||||
},
|
||||
"keywords": [
|
||||
"esengine",
|
||||
"network",
|
||||
"server",
|
||||
"tsrpc",
|
||||
"websocket"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* @esengine/network-server
|
||||
*
|
||||
* 基于 TSRPC 的网络服务器模块
|
||||
* TSRPC-based network server module
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Re-export from protocols | 从协议包重新导出
|
||||
// ============================================================================
|
||||
|
||||
export type {
|
||||
ServiceType,
|
||||
IEntityState,
|
||||
IPlayerInput,
|
||||
MsgSync,
|
||||
MsgInput,
|
||||
MsgSpawn,
|
||||
MsgDespawn,
|
||||
ReqJoin,
|
||||
ResJoin
|
||||
} from '@esengine/network-protocols';
|
||||
|
||||
export { serviceProto } from '@esengine/network-protocols';
|
||||
|
||||
// ============================================================================
|
||||
// Server | 服务器
|
||||
// ============================================================================
|
||||
|
||||
export { GameServer, type IServerConfig } from './services/GameServer';
|
||||
export { Room, type IPlayer, type IRoomConfig } from './services/Room';
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* 服务器入口
|
||||
* Server entry point
|
||||
*/
|
||||
import { GameServer } from './services/GameServer';
|
||||
|
||||
const PORT = parseInt(process.env['PORT'] ?? '3000', 10);
|
||||
|
||||
const server = new GameServer({
|
||||
port: PORT,
|
||||
roomConfig: {
|
||||
maxPlayers: 16,
|
||||
tickRate: 20
|
||||
}
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
// Start server
|
||||
server.start().catch((err) => {
|
||||
console.error('[Main] 服务器启动失败 | Server failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 优雅关闭
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n[Main] 正在关闭服务器... | Shutting down server...');
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import { WsServer, type BaseConnection } from 'tsrpc';
|
||||
import { serviceProto, type ServiceType, type MsgInput } from '@esengine/network-protocols';
|
||||
import { Room, type IRoomConfig } from './Room';
|
||||
|
||||
/**
|
||||
* 服务器配置
|
||||
* Server configuration
|
||||
*/
|
||||
export interface IServerConfig {
|
||||
port: number;
|
||||
roomConfig?: Partial<IRoomConfig>;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: IServerConfig = {
|
||||
port: 3000
|
||||
};
|
||||
|
||||
/**
|
||||
* 游戏服务器
|
||||
* Game server
|
||||
*
|
||||
* 管理 WebSocket 连接和房间。
|
||||
* Manages WebSocket connections and rooms.
|
||||
*/
|
||||
export class GameServer {
|
||||
private _server: WsServer<ServiceType>;
|
||||
private _config: IServerConfig;
|
||||
private _rooms: Map<string, Room> = new Map();
|
||||
private _connectionToRoom: Map<BaseConnection<ServiceType>, { roomId: string; clientId: number }> = new Map();
|
||||
|
||||
constructor(config: Partial<IServerConfig> = {}) {
|
||||
this._config = { ...DEFAULT_CONFIG, ...config };
|
||||
this._server = new WsServer(serviceProto, {
|
||||
port: this._config.port,
|
||||
json: true,
|
||||
logLevel: 'info'
|
||||
});
|
||||
|
||||
this._setupHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动服务器
|
||||
* Start server
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
await this._server.start();
|
||||
console.log(`[GameServer] 服务器已启动 | Server started on port ${this._config.port}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止服务器
|
||||
* Stop server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
// 销毁所有房间
|
||||
// Destroy all rooms
|
||||
for (const room of this._rooms.values()) {
|
||||
room.destroy();
|
||||
}
|
||||
this._rooms.clear();
|
||||
this._connectionToRoom.clear();
|
||||
|
||||
await this._server.stop();
|
||||
console.log('[GameServer] 服务器已停止 | Server stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建房间
|
||||
* Get or create room
|
||||
*/
|
||||
getOrCreateRoom(roomId?: string): Room {
|
||||
// 如果没有指定房间 ID,寻找未满的房间或创建新房间
|
||||
// If no room ID specified, find a non-full room or create new one
|
||||
if (!roomId) {
|
||||
for (const room of this._rooms.values()) {
|
||||
if (!room.isFull) {
|
||||
return room;
|
||||
}
|
||||
}
|
||||
roomId = this._generateRoomId();
|
||||
}
|
||||
|
||||
let room = this._rooms.get(roomId);
|
||||
if (!room) {
|
||||
room = new Room(roomId, this._config.roomConfig);
|
||||
this._rooms.set(roomId, room);
|
||||
console.log(`[GameServer] 创建房间 | Room created: ${roomId}`);
|
||||
}
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取房间
|
||||
* Get room
|
||||
*/
|
||||
getRoom(roomId: string): Room | undefined {
|
||||
return this._rooms.get(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接的房间信息
|
||||
* Get connection's room info
|
||||
*/
|
||||
getConnectionInfo(connection: BaseConnection<ServiceType>): { roomId: string; clientId: number } | undefined {
|
||||
return this._connectionToRoom.get(connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置连接的房间信息
|
||||
* Set connection's room info
|
||||
*/
|
||||
setConnectionInfo(connection: BaseConnection<ServiceType>, roomId: string, clientId: number): void {
|
||||
this._connectionToRoom.set(connection, { roomId, clientId });
|
||||
}
|
||||
|
||||
private _setupHandlers(): void {
|
||||
// 处理加入请求
|
||||
// Handle join request
|
||||
this._server.implementApi('Join', async (call) => {
|
||||
const { playerName, roomId } = call.req;
|
||||
|
||||
const room = this.getOrCreateRoom(roomId);
|
||||
if (room.isFull) {
|
||||
call.error('房间已满 | Room is full');
|
||||
return;
|
||||
}
|
||||
|
||||
const player = room.addPlayer(playerName, call.conn);
|
||||
if (!player) {
|
||||
call.error('加入房间失败 | Failed to join room');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setConnectionInfo(call.conn, room.id, player.clientId);
|
||||
|
||||
// 向新玩家发送自己的生成消息
|
||||
// Send spawn message to new player for themselves
|
||||
call.conn.sendMsg('Spawn', {
|
||||
netId: player.netId,
|
||||
ownerId: player.clientId,
|
||||
prefab: 'player',
|
||||
pos: { x: 0, y: 0 },
|
||||
rot: 0
|
||||
});
|
||||
|
||||
call.succ({
|
||||
clientId: player.clientId,
|
||||
roomId: room.id,
|
||||
playerCount: room.playerCount
|
||||
});
|
||||
|
||||
console.log(`[GameServer] 玩家加入 | Player joined: ${playerName} (${player.clientId}) -> ${room.id}`);
|
||||
});
|
||||
|
||||
// 处理输入消息
|
||||
// Handle input message
|
||||
this._server.listenMsg('Input', (call) => {
|
||||
const info = this.getConnectionInfo(call.conn);
|
||||
if (!info) return;
|
||||
|
||||
const room = this.getRoom(info.roomId);
|
||||
if (!room) return;
|
||||
|
||||
const msg = call.msg as MsgInput;
|
||||
room.handleInput(info.clientId, msg.input);
|
||||
});
|
||||
|
||||
// 处理断开连接
|
||||
// Handle disconnect
|
||||
this._server.flows.postDisconnectFlow.push((v) => {
|
||||
const info = this._connectionToRoom.get(v.conn);
|
||||
if (info) {
|
||||
const room = this.getRoom(info.roomId);
|
||||
if (room) {
|
||||
room.removePlayer(info.clientId);
|
||||
console.log(`[GameServer] 玩家离开 | Player left: ${info.clientId} from ${info.roomId}`);
|
||||
|
||||
// 如果房间空了,删除房间
|
||||
// If room is empty, delete it
|
||||
if (room.playerCount === 0) {
|
||||
room.destroy();
|
||||
this._rooms.delete(info.roomId);
|
||||
console.log(`[GameServer] 删除空房间 | Empty room deleted: ${info.roomId}`);
|
||||
}
|
||||
}
|
||||
this._connectionToRoom.delete(v.conn);
|
||||
}
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
private _generateRoomId(): string {
|
||||
return Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import type { BaseConnection } from 'tsrpc';
|
||||
import type { ServiceType, IEntityState } from '@esengine/network-protocols';
|
||||
|
||||
/**
|
||||
* 连接类型别名
|
||||
* Connection type alias
|
||||
*/
|
||||
type Connection = BaseConnection<ServiceType>;
|
||||
|
||||
/**
|
||||
* 玩家信息
|
||||
* Player information
|
||||
*/
|
||||
export interface IPlayer {
|
||||
clientId: number;
|
||||
name: string;
|
||||
connection: Connection;
|
||||
netId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 房间配置
|
||||
* Room configuration
|
||||
*/
|
||||
export interface IRoomConfig {
|
||||
maxPlayers: number;
|
||||
tickRate: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: IRoomConfig = {
|
||||
maxPlayers: 16,
|
||||
tickRate: 20
|
||||
};
|
||||
|
||||
/**
|
||||
* 游戏房间
|
||||
* Game room
|
||||
*
|
||||
* 管理房间内的玩家和实体状态同步。
|
||||
* Manages players and entity state synchronization within a room.
|
||||
*/
|
||||
export class Room {
|
||||
private _id: string;
|
||||
private _config: IRoomConfig;
|
||||
private _players: Map<number, IPlayer> = new Map();
|
||||
private _entities: Map<number, IEntityState> = new Map();
|
||||
private _nextClientId: number = 1;
|
||||
private _nextNetId: number = 1;
|
||||
private _syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(id: string, config: Partial<IRoomConfig> = {}) {
|
||||
this._id = id;
|
||||
this._config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get playerCount(): number {
|
||||
return this._players.size;
|
||||
}
|
||||
|
||||
get isFull(): boolean {
|
||||
return this._players.size >= this._config.maxPlayers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加玩家
|
||||
* Add player
|
||||
*/
|
||||
addPlayer(name: string, connection: Connection): IPlayer | null {
|
||||
if (this.isFull) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clientId = this._nextClientId++;
|
||||
const netId = this._nextNetId++;
|
||||
|
||||
const player: IPlayer = {
|
||||
clientId,
|
||||
name,
|
||||
connection,
|
||||
netId
|
||||
};
|
||||
|
||||
this._players.set(clientId, player);
|
||||
|
||||
// 创建玩家实体
|
||||
// Create player entity
|
||||
const entityState: IEntityState = {
|
||||
netId,
|
||||
pos: { x: 0, y: 0 },
|
||||
rot: 0
|
||||
};
|
||||
this._entities.set(netId, entityState);
|
||||
|
||||
// 通知其他玩家
|
||||
// Notify other players
|
||||
this._broadcastSpawn(player, entityState);
|
||||
|
||||
// 同步现有实体给新玩家
|
||||
// Sync existing entities to new player
|
||||
this._syncExistingEntities(player);
|
||||
|
||||
// 启动同步循环
|
||||
// Start sync loop
|
||||
if (this._syncInterval === null) {
|
||||
this._startSyncLoop();
|
||||
}
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除玩家
|
||||
* Remove player
|
||||
*/
|
||||
removePlayer(clientId: number): void {
|
||||
const player = this._players.get(clientId);
|
||||
if (!player) return;
|
||||
|
||||
this._players.delete(clientId);
|
||||
this._entities.delete(player.netId);
|
||||
|
||||
// 通知其他玩家
|
||||
// Notify other players
|
||||
this._broadcastDespawn(player.netId);
|
||||
|
||||
// 停止同步循环
|
||||
// Stop sync loop
|
||||
if (this._players.size === 0 && this._syncInterval !== null) {
|
||||
clearInterval(this._syncInterval);
|
||||
this._syncInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理玩家输入
|
||||
* Handle player input
|
||||
*/
|
||||
handleInput(
|
||||
clientId: number,
|
||||
input: { moveDir?: { x: number; y: number }; actions?: string[] }
|
||||
): void {
|
||||
const player = this._players.get(clientId);
|
||||
if (!player) return;
|
||||
|
||||
const entity = this._entities.get(player.netId);
|
||||
if (!entity || !entity.pos) return;
|
||||
|
||||
// 简单的移动处理
|
||||
// Simple movement handling
|
||||
if (input.moveDir) {
|
||||
const speed = 5;
|
||||
entity.pos.x += input.moveDir.x * speed;
|
||||
entity.pos.y += input.moveDir.y * speed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取玩家
|
||||
* Get player
|
||||
*/
|
||||
getPlayer(clientId: number): IPlayer | undefined {
|
||||
return this._players.get(clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁房间
|
||||
* Destroy room
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this._syncInterval !== null) {
|
||||
clearInterval(this._syncInterval);
|
||||
this._syncInterval = null;
|
||||
}
|
||||
this._players.clear();
|
||||
this._entities.clear();
|
||||
}
|
||||
|
||||
private _startSyncLoop(): void {
|
||||
const interval = 1000 / this._config.tickRate;
|
||||
this._syncInterval = setInterval(() => {
|
||||
this._broadcastSync();
|
||||
}, interval);
|
||||
}
|
||||
|
||||
private _broadcastSync(): void {
|
||||
if (this._players.size === 0) return;
|
||||
|
||||
const entities = Array.from(this._entities.values());
|
||||
const time = Date.now();
|
||||
|
||||
for (const player of this._players.values()) {
|
||||
player.connection.sendMsg('Sync', { time, entities });
|
||||
}
|
||||
}
|
||||
|
||||
private _broadcastSpawn(newPlayer: IPlayer, state: IEntityState): void {
|
||||
for (const player of this._players.values()) {
|
||||
if (player.clientId === newPlayer.clientId) continue;
|
||||
|
||||
player.connection.sendMsg('Spawn', {
|
||||
netId: state.netId,
|
||||
ownerId: newPlayer.clientId,
|
||||
prefab: 'player',
|
||||
pos: state.pos ?? { x: 0, y: 0 },
|
||||
rot: state.rot ?? 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _broadcastDespawn(netId: number): void {
|
||||
for (const player of this._players.values()) {
|
||||
player.connection.sendMsg('Despawn', { netId });
|
||||
}
|
||||
}
|
||||
|
||||
private _syncExistingEntities(newPlayer: IPlayer): void {
|
||||
for (const [netId, state] of this._entities) {
|
||||
const owner = Array.from(this._players.values()).find((p) => p.netId === netId);
|
||||
if (!owner || owner.clientId === newPlayer.clientId) continue;
|
||||
|
||||
newPlayer.connection.sendMsg('Spawn', {
|
||||
netId,
|
||||
ownerId: owner.clientId,
|
||||
prefab: 'player',
|
||||
pos: state.pos ?? { x: 0, y: 0 },
|
||||
rot: state.rot ?? 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../network-ext/network-protocols"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts', 'src/main.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: ['tsrpc'],
|
||||
tsconfig: 'tsconfig.build.json'
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { IScene, ServiceContainer, IComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, SystemContext } from '@esengine/engine-core';
|
||||
import { ChunkComponent } from './components/ChunkComponent';
|
||||
import { StreamingAnchorComponent } from './components/StreamingAnchorComponent';
|
||||
import { ChunkLoaderComponent } from './components/ChunkLoaderComponent';
|
||||
import { ChunkStreamingSystem } from './systems/ChunkStreamingSystem';
|
||||
import { ChunkCullingSystem } from './systems/ChunkCullingSystem';
|
||||
import { ChunkManager } from './services/ChunkManager';
|
||||
|
||||
/**
|
||||
* 世界流式加载模块
|
||||
*
|
||||
* Runtime module for world streaming functionality.
|
||||
*
|
||||
* 提供世界流式加载功能的运行时模块。
|
||||
*/
|
||||
export class WorldStreamingModule implements IRuntimeModule {
|
||||
private _chunkManager: ChunkManager | null = null;
|
||||
|
||||
get chunkManager(): ChunkManager | null {
|
||||
return this._chunkManager;
|
||||
}
|
||||
|
||||
registerComponents(registry: IComponentRegistry): void {
|
||||
registry.register(ChunkComponent);
|
||||
registry.register(StreamingAnchorComponent);
|
||||
registry.register(ChunkLoaderComponent);
|
||||
}
|
||||
|
||||
registerServices(services: ServiceContainer): void {
|
||||
this._chunkManager = new ChunkManager();
|
||||
services.registerInstance(ChunkManager, this._chunkManager);
|
||||
}
|
||||
|
||||
createSystems(scene: IScene, _context: SystemContext): void {
|
||||
const streamingSystem = new ChunkStreamingSystem();
|
||||
if (this._chunkManager) {
|
||||
streamingSystem.setChunkManager(this._chunkManager);
|
||||
}
|
||||
scene.addSystem(streamingSystem);
|
||||
scene.addSystem(new ChunkCullingSystem());
|
||||
}
|
||||
|
||||
onSystemsCreated(_scene: IScene, _context: SystemContext): void {
|
||||
// No post-creation setup needed
|
||||
}
|
||||
}
|
||||
|
||||
export const worldStreamingModule = new WorldStreamingModule();
|
||||
26
packages/tools/create-esengine-server/CHANGELOG.md
Normal file
26
packages/tools/create-esengine-server/CHANGELOG.md
Normal 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
|
||||
39
packages/tools/create-esengine-server/package.json
Normal file
39
packages/tools/create-esengine-server/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "create-esengine-server",
|
||||
"version": "1.1.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"
|
||||
]
|
||||
}
|
||||
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'],
|
||||
})
|
||||
@@ -1,5 +1,12 @@
|
||||
# @esengine/demos
|
||||
|
||||
## 1.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`0662b07`](https://github.com/esengine/esengine/commit/0662b074454906ad7c0264fe1d3a241f13730ba1)]:
|
||||
- @esengine/pathfinding@1.0.4
|
||||
|
||||
## 1.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/demos",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"description": "Demo tests for ESEngine modules documentation",
|
||||
"type": "module",
|
||||
|
||||
147
pnpm-lock.yaml
generated
147
pnpm-lock.yaml
generated
@@ -1053,7 +1053,7 @@ importers:
|
||||
dependencies:
|
||||
'@esengine/world-streaming':
|
||||
specifier: workspace:*
|
||||
version: link:../../../streaming/world-streaming
|
||||
version: link:../../../framework/world-streaming
|
||||
devDependencies:
|
||||
'@esengine/build-config':
|
||||
specifier: workspace:*
|
||||
@@ -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:
|
||||
@@ -1738,24 +1763,24 @@ importers:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/network-ext/network-server:
|
||||
packages/framework/world-streaming:
|
||||
dependencies:
|
||||
'@esengine/network-protocols':
|
||||
'@esengine/ecs-framework':
|
||||
specifier: workspace:*
|
||||
version: link:../../framework/network-protocols
|
||||
tsrpc:
|
||||
specifier: ^3.4.15
|
||||
version: 3.4.21
|
||||
ws:
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.3
|
||||
version: link:../core/dist
|
||||
'@esengine/ecs-framework-math':
|
||||
specifier: workspace:*
|
||||
version: link:../math
|
||||
'@esengine/spatial':
|
||||
specifier: workspace:*
|
||||
version: link:../spatial
|
||||
devDependencies:
|
||||
tsup:
|
||||
specifier: ^8.5.1
|
||||
specifier: ^8.0.1
|
||||
version: 8.5.1(@microsoft/api-extractor@7.55.2(@types/node@22.19.3))(@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)
|
||||
tsx:
|
||||
specifier: ^4.19.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/physics/physics-rapier2d:
|
||||
dependencies:
|
||||
@@ -2041,22 +2066,6 @@ importers:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.10
|
||||
|
||||
packages/streaming/world-streaming:
|
||||
dependencies:
|
||||
'@esengine/ecs-framework':
|
||||
specifier: workspace:*
|
||||
version: link:../../framework/core/dist
|
||||
'@esengine/engine-core':
|
||||
specifier: workspace:*
|
||||
version: link:../../engine/engine-core
|
||||
devDependencies:
|
||||
tsup:
|
||||
specifier: ^8.0.1
|
||||
version: 8.5.1(@microsoft/api-extractor@7.55.2(@types/node@22.19.3))(@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.3.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/tools/build-config:
|
||||
devDependencies:
|
||||
'@vitejs/plugin-react':
|
||||
@@ -2100,6 +2109,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':
|
||||
@@ -5204,9 +5238,6 @@ packages:
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@types/ws@7.4.7':
|
||||
resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
@@ -10217,9 +10248,6 @@ packages:
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tsrpc-base-client@2.1.17:
|
||||
resolution: {integrity: sha512-z3Ei2jgJUWt4Mmf9AWXk0BctXE2FzfVRLitbDgZ9byixjuIQ2rDyDxjR8ae7kiR17Dx4YVIEJ8FW/2180lA5iw==}
|
||||
|
||||
tsrpc-cli@2.4.5:
|
||||
resolution: {integrity: sha512-/3MMyGAAuSnZLQVfoRZXI5sfyGakRTk2AfrllvVSUSfGPTr06iU1YAgOATNYEHl+uAj1+QFz3dKT8g3J+wCIcw==}
|
||||
hasBin: true
|
||||
@@ -10227,9 +10255,6 @@ packages:
|
||||
tsrpc-proto@1.4.3:
|
||||
resolution: {integrity: sha512-qtkk5i34m9/K1258EdyXAEikU/ADPELHCCXN/oFJ4XwH+kN3kXnKYmwCDblUuMA73V2+A/EwkgUGyAgPa335Hw==}
|
||||
|
||||
tsrpc@3.4.21:
|
||||
resolution: {integrity: sha512-8NdFmEkB5t1raKPuWzwl/qwivbacxsuFzpTDv2nlHPVNBP7fwmIOY6uZT8g/SSaAEUMAKt8fCeOYYtwsvjwjqw==}
|
||||
|
||||
tsup@8.5.1:
|
||||
resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -10635,10 +10660,6 @@ packages:
|
||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||
hasBin: true
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
hasBin: true
|
||||
|
||||
v8-compile-cache-lib@3.0.1:
|
||||
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
||||
|
||||
@@ -10955,18 +10976,6 @@ packages:
|
||||
resolution: {integrity: sha512-v2UQ+50TNf2rNHJ8NyWttfm/EJUBWMJcx6ZTYZr6Qp52uuegWw/lBkCtCbnYZEmPRNL61m+u67dAmGxo+HTULA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ws@7.5.10:
|
||||
resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==}
|
||||
engines: {node: '>=8.3.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: ^5.0.2
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.18.3:
|
||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -14723,10 +14732,6 @@ snapshots:
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@types/ws@7.4.7':
|
||||
dependencies:
|
||||
'@types/node': 20.19.27
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 20.19.27
|
||||
@@ -20970,13 +20975,6 @@ snapshots:
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsrpc-base-client@2.1.17:
|
||||
dependencies:
|
||||
k8w-extend-native: 1.4.6
|
||||
tsbuffer: 2.2.23
|
||||
tslib: 2.8.1
|
||||
tsrpc-proto: 1.4.3
|
||||
|
||||
tsrpc-cli@2.4.5(@swc/core@1.15.7(@swc/helpers@0.5.18))(@swc/wasm@1.15.7)(@types/node@22.19.3):
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
@@ -21005,19 +21003,6 @@ snapshots:
|
||||
tsbuffer-schema: 2.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
tsrpc@3.4.21:
|
||||
dependencies:
|
||||
'@types/ws': 7.4.7
|
||||
chalk: 4.1.2
|
||||
tsbuffer: 2.2.23
|
||||
tsrpc-base-client: 2.1.17
|
||||
tsrpc-proto: 1.4.3
|
||||
uuid: 8.3.2
|
||||
ws: 7.5.10
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
tsup@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):
|
||||
dependencies:
|
||||
bundle-require: 5.1.0(esbuild@0.27.2)
|
||||
@@ -21374,8 +21359,6 @@ snapshots:
|
||||
|
||||
uuid@10.0.0: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
v8-compile-cache-lib@3.0.1: {}
|
||||
|
||||
v8-to-istanbul@9.3.0:
|
||||
@@ -21764,8 +21747,6 @@ snapshots:
|
||||
type-fest: 0.4.1
|
||||
write-json-file: 3.2.0
|
||||
|
||||
ws@7.5.10: {}
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
|
||||
Reference in New Issue
Block a user