Compare commits

...

16 Commits

Author SHA1 Message Date
github-actions[bot]
2d46ccf896 chore: release packages (#380)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 10:44:48 +08:00
YHH
fb8bde6485 feat(network): 网络模块增强 - 预测、AOI、增量压缩 (#379)
- 添加 NetworkPredictionSystem 客户端预测系统
- 添加 NetworkAOISystem 兴趣区域管理
- 添加 StateDeltaCompressor 状态增量压缩
- 添加断线重连和状态恢复
- 增强协议支持时间戳、序列号、速度
- 添加中英文文档
2025-12-29 10:42:48 +08:00
yhh
30437dc5d5 docs: add world-streaming to sidebar navigation 2025-12-28 19:50:44 +08:00
YHH
9f84c2f870 chore: bump pathfinding and world-streaming to 1.1.0 (#378) 2025-12-28 19:36:34 +08:00
github-actions[bot]
e9ea52d9b3 chore: release packages (#377)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 19:26:41 +08:00
YHH
0662b07445 chore: update pathfinding, add rpc/world-streaming docs, refactor world-streaming location (#376) 2025-12-28 19:18:28 +08:00
github-actions[bot]
838cda91aa chore: release packages (#375)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 14:11:27 +08:00
YHH
a000cc07d7 feat(rpc): export RpcClient from main entry point (#374) 2025-12-28 14:09:16 +08:00
github-actions[bot]
1316d7de49 chore: release packages (#373)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 13:49:34 +08:00
YHH
9c41181875 fix(server): expose id property on ServerConnection type (#372) 2025-12-28 13:47:27 +08:00
github-actions[bot]
9f3f9a547a chore: release packages (#371)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 13:32:44 +08:00
YHH
18df9d1cda fix(server): allow define() to be called before start() (#370) 2025-12-28 13:29:17 +08:00
github-actions[bot]
9a4b3388e0 chore: release packages (#369)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 12:51:20 +08:00
YHH
66d5dc27f7 fix(server): 修复发布缺少 dist | fix missing dist (#368)
* fix(server): 修复发布缺少 dist | fix missing dist in publish

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

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

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

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

View File

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

View File

@@ -268,6 +268,17 @@ export default defineConfig({
{ label: 'API 参考', slug: 'modules/network/api', translations: { en: 'API Reference' } },
],
},
{
label: '世界流式加载',
translations: { en: 'World Streaming' },
items: [
{ label: '概述', slug: 'modules/world-streaming', translations: { en: 'Overview' } },
{ label: '区块管理', slug: 'modules/world-streaming/chunk-manager', translations: { en: 'Chunk Manager' } },
{ label: '流式系统', slug: 'modules/world-streaming/streaming-system', translations: { en: 'Streaming System' } },
{ label: '序列化', slug: 'modules/world-streaming/serialization', translations: { en: 'Serialization' } },
{ label: '实际示例', slug: 'modules/world-streaming/examples', translations: { en: 'Examples' } },
],
},
],
},
{

View File

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

View File

@@ -0,0 +1,283 @@
---
title: "Area of Interest (AOI)"
description: "View range based network entity filtering"
---
AOI (Area of Interest) is a key technique in large-scale multiplayer games for optimizing network bandwidth. By only synchronizing entities within a player's view range, network traffic can be significantly reduced.
## NetworkAOISystem
`NetworkAOISystem` provides grid-based area of interest management.
### Enable AOI
```typescript
import { NetworkPlugin } from '@esengine/network';
const networkPlugin = new NetworkPlugin({
enableAOI: true,
aoiConfig: {
cellSize: 100, // Grid cell size
defaultViewRange: 500, // Default view range
enabled: true,
}
});
await Core.installPlugin(networkPlugin);
```
### Adding Observers
Each player that needs to receive sync data must be added as an observer:
```typescript
// Add observer when player joins
networkPlugin.registerPrefab('player', (scene, spawn) => {
const entity = scene.createEntity(`player_${spawn.netId}`);
// ... setup components
// Add player as AOI observer
networkPlugin.addAOIObserver(
spawn.netId, // Network ID
spawn.pos.x, // Initial X position
spawn.pos.y, // Initial Y position
600 // View range (optional)
);
return entity;
});
// Remove observer when player leaves
networkPlugin.removeAOIObserver(playerNetId);
```
### Updating Observer Position
When a player moves, update their AOI position:
```typescript
// Update in game loop or sync callback
networkPlugin.updateAOIObserverPosition(playerNetId, newX, newY);
```
## AOI Configuration
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `cellSize` | `number` | 100 | Grid cell size |
| `defaultViewRange` | `number` | 500 | Default view range |
| `enabled` | `boolean` | true | Whether AOI is enabled |
### Grid Size Recommendations
Grid size should be set based on game view range:
```typescript
// Recommendation: cellSize = defaultViewRange / 3 to / 5
aoiConfig: {
cellSize: 100,
defaultViewRange: 500, // Grid is about 1/5 of view range
}
```
## Query Interface
### Get Visible Entities
```typescript
// Get all entities visible to player
const visibleEntities = networkPlugin.getVisibleEntities(playerNetId);
console.log('Visible entities:', visibleEntities);
```
### Check Visibility
```typescript
// Check if player can see an entity
if (networkPlugin.canSee(playerNetId, targetEntityNetId)) {
// Target is in view
}
```
## Event Listening
The AOI system triggers events when entities enter/exit view:
```typescript
const aoiSystem = networkPlugin.aoiSystem;
if (aoiSystem) {
aoiSystem.addListener((event) => {
if (event.type === 'enter') {
console.log(`Entity ${event.targetNetId} entered view of ${event.observerNetId}`);
// Can send entity's initial state here
} else if (event.type === 'exit') {
console.log(`Entity ${event.targetNetId} left view of ${event.observerNetId}`);
// Can cleanup resources here
}
});
}
```
## Server-Side Filtering
AOI is most commonly used server-side to filter sync data for each client:
```typescript
// Server-side example
import { NetworkAOISystem, createNetworkAOISystem } from '@esengine/network';
class GameServer {
private aoiSystem = createNetworkAOISystem({
cellSize: 100,
defaultViewRange: 500,
});
// Player joins
onPlayerJoin(playerId: number, x: number, y: number) {
this.aoiSystem.addObserver(playerId, x, y);
}
// Player moves
onPlayerMove(playerId: number, x: number, y: number) {
this.aoiSystem.updateObserverPosition(playerId, x, y);
}
// Send sync data
broadcastSync(allEntities: EntitySyncState[]) {
for (const playerId of this.players) {
// Filter using AOI
const filteredEntities = this.aoiSystem.filterSyncData(
playerId,
allEntities
);
// Send only visible entities
this.sendToPlayer(playerId, { entities: filteredEntities });
}
}
}
```
## How It Works
```
┌─────────────────────────────────────────────────────────────┐
│ Game World │
│ ┌─────┬─────┬─────┬─────┬─────┐ │
│ │ │ │ E │ │ │ │
│ ├─────┼─────┼─────┼─────┼─────┤ E = Enemy entity │
│ │ │ P │ ● │ │ │ P = Player │
│ ├─────┼─────┼─────┼─────┼─────┤ ● = Player view center │
│ │ │ │ E │ E │ │ ○ = View range │
│ ├─────┼─────┼─────┼─────┼─────┤ │
│ │ │ │ │ │ E │ Player only sees E in view│
│ └─────┴─────┴─────┴─────┴─────┘ │
│ │
│ View range (circle): Contains 3 enemies │
│ Grid optimization: Only check cells covered by view │
└─────────────────────────────────────────────────────────────┘
```
### Grid Optimization
AOI uses spatial grid to accelerate queries:
1. **Add Entity**: Calculate grid cell based on position
2. **View Detection**: Only check cells covered by view range
3. **Move Update**: Update cell assignment when crossing cells
4. **Event Trigger**: Detect enter/exit view
## Dynamic View Range
Different player types can have different view ranges:
```typescript
// Regular player
networkPlugin.addAOIObserver(playerId, x, y, 500);
// VIP player (larger view)
networkPlugin.addAOIObserver(vipPlayerId, x, y, 800);
// Adjust view range at runtime
const aoiSystem = networkPlugin.aoiSystem;
if (aoiSystem) {
aoiSystem.updateObserverViewRange(playerId, 600);
}
```
## Best Practices
### 1. Server-Side Usage
AOI filtering should be done server-side; clients should not trust their own AOI judgment:
```typescript
// Filter on server before sending
const filtered = aoiSystem.filterSyncData(playerId, entities);
sendToClient(playerId, filtered);
```
### 2. Edge Handling
Add buffer zone at view edge to prevent flickering:
```typescript
// Add immediately when entering view
// Remove with delay when exiting (keep for 1-2 extra seconds)
aoiSystem.addListener((event) => {
if (event.type === 'exit') {
setTimeout(() => {
// Re-check if really exited
if (!aoiSystem.canSee(event.observerNetId, event.targetNetId)) {
removeFromClient(event.observerNetId, event.targetNetId);
}
}, 1000);
}
});
```
### 3. Large Entities
Large entities (like bosses) may need special handling:
```typescript
// Boss is always visible to everyone
function filterWithBoss(playerId: number, entities: EntitySyncState[]) {
const filtered = aoiSystem.filterSyncData(playerId, entities);
// Add boss entity
const bossState = entities.find(e => e.netId === bossNetId);
if (bossState && !filtered.includes(bossState)) {
filtered.push(bossState);
}
return filtered;
}
```
### 4. Performance Considerations
```typescript
// Large-scale game recommended config
aoiConfig: {
cellSize: 200, // Larger grid reduces cell count
defaultViewRange: 800, // Set based on actual view
}
```
## Debugging
```typescript
const aoiSystem = networkPlugin.aoiSystem;
if (aoiSystem) {
console.log('AOI enabled:', aoiSystem.enabled);
console.log('Observer count:', aoiSystem.observerCount);
// Get visible entities for specific player
const visible = aoiSystem.getVisibleEntities(playerId);
console.log('Visible entities:', visible.length);
}
```

View File

@@ -0,0 +1,316 @@
---
title: "State Delta Compression"
description: "Reduce network bandwidth with incremental sync"
---
State delta compression reduces network bandwidth by only sending fields that have changed. For frequently synchronized game state, this can significantly reduce data transmission.
## StateDeltaCompressor
The `StateDeltaCompressor` class is used to compress and decompress state deltas.
### Basic Usage
```typescript
import { createStateDeltaCompressor, type SyncData } from '@esengine/network';
// Create compressor
const compressor = createStateDeltaCompressor({
positionThreshold: 0.01, // Position change threshold
rotationThreshold: 0.001, // Rotation change threshold (radians)
velocityThreshold: 0.1, // Velocity change threshold
fullSnapshotInterval: 60, // Full snapshot interval (frames)
});
// Compress sync data
const syncData: SyncData = {
frame: 100,
timestamp: Date.now(),
entities: [
{ netId: 1, pos: { x: 100, y: 200 }, rot: 0 },
{ netId: 2, pos: { x: 300, y: 400 }, rot: 1.5 },
],
};
const deltaData = compressor.compress(syncData);
// deltaData only contains changed fields
// Decompress delta data
const fullData = compressor.decompress(deltaData);
```
## Configuration Options
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `positionThreshold` | `number` | 0.01 | Position change threshold |
| `rotationThreshold` | `number` | 0.001 | Rotation change threshold (radians) |
| `velocityThreshold` | `number` | 0.1 | Velocity change threshold |
| `fullSnapshotInterval` | `number` | 60 | Full snapshot interval (frames) |
## Delta Flags
Bit flags indicate which fields have changed:
```typescript
import { DeltaFlags } from '@esengine/network';
// Flag definitions
DeltaFlags.NONE // 0 - No change
DeltaFlags.POSITION // 1 - Position changed
DeltaFlags.ROTATION // 2 - Rotation changed
DeltaFlags.VELOCITY // 4 - Velocity changed
DeltaFlags.ANGULAR_VELOCITY // 8 - Angular velocity changed
DeltaFlags.CUSTOM // 16 - Custom data changed
```
## Data Format
### Full State
```typescript
interface EntitySyncState {
netId: number;
pos?: { x: number; y: number };
rot?: number;
vel?: { x: number; y: number };
angVel?: number;
custom?: Record<string, unknown>;
}
```
### Delta State
```typescript
interface EntityDeltaState {
netId: number;
flags: number; // Change flags
pos?: { x: number; y: number }; // Only present when POSITION flag set
rot?: number; // Only present when ROTATION flag set
vel?: { x: number; y: number }; // Only present when VELOCITY flag set
angVel?: number; // Only present when ANGULAR_VELOCITY flag set
custom?: Record<string, unknown>; // Only present when CUSTOM flag set
}
```
## How It Works
```
Frame 1 (full snapshot):
Entity 1: pos=(100, 200), rot=0
Frame 2 (delta):
Entity 1: flags=POSITION, pos=(101, 200) // Only X changed
Frame 3 (delta):
Entity 1: flags=0 // No change, not sent
Frame 4 (delta):
Entity 1: flags=POSITION|ROTATION, pos=(105, 200), rot=0.5
Frame 60 (forced full snapshot):
Entity 1: pos=(200, 300), rot=1.0, vel=(5, 0)
```
## Server-Side Usage
```typescript
import { createStateDeltaCompressor } from '@esengine/network';
class GameServer {
private compressor = createStateDeltaCompressor();
// Broadcast state updates
broadcastState(entities: EntitySyncState[]) {
const syncData: SyncData = {
frame: this.currentFrame,
timestamp: Date.now(),
entities,
};
// Compress data
const deltaData = this.compressor.compress(syncData);
// Send delta data
this.broadcast('sync', deltaData);
}
// Cleanup when player leaves
onPlayerLeave(netId: number) {
this.compressor.removeEntity(netId);
}
}
```
## Client-Side Usage
```typescript
class GameClient {
private compressor = createStateDeltaCompressor();
// Receive delta data
onSyncReceived(deltaData: DeltaSyncData) {
// Decompress to full state
const fullData = this.compressor.decompress(deltaData);
// Apply state
for (const entity of fullData.entities) {
this.applyEntityState(entity);
}
}
}
```
## Bandwidth Savings Example
Assume each entity has the following data:
| Field | Size (bytes) |
|-------|-------------|
| netId | 4 |
| pos.x | 8 |
| pos.y | 8 |
| rot | 8 |
| vel.x | 8 |
| vel.y | 8 |
| angVel | 8 |
| **Total** | **52** |
With delta compression:
| Scenario | Original | Compressed | Savings |
|----------|----------|------------|---------|
| Only position changed | 52 | 4+1+16 = 21 | 60% |
| Only rotation changed | 52 | 4+1+8 = 13 | 75% |
| Stationary | 52 | 0 | 100% |
| Position + rotation changed | 52 | 4+1+24 = 29 | 44% |
## Forcing Full Snapshot
Some situations require sending full snapshots:
```typescript
// When new player joins
compressor.forceFullSnapshot();
const data = compressor.compress(syncData);
// This will send full state
// On reconnection
compressor.clear(); // Clear history
compressor.forceFullSnapshot();
```
## Custom Data
Support for syncing custom game data:
```typescript
const syncData: SyncData = {
frame: 100,
timestamp: Date.now(),
entities: [
{
netId: 1,
pos: { x: 100, y: 200 },
custom: {
health: 80,
mana: 50,
buffs: ['speed', 'shield'],
},
},
],
};
// Custom data is also delta compressed
const deltaData = compressor.compress(syncData);
```
## Best Practices
### 1. Set Appropriate Thresholds
```typescript
// High precision games (e.g., competitive)
const compressor = createStateDeltaCompressor({
positionThreshold: 0.001,
rotationThreshold: 0.0001,
});
// Casual games
const compressor = createStateDeltaCompressor({
positionThreshold: 0.1,
rotationThreshold: 0.01,
});
```
### 2. Adjust Full Snapshot Interval
```typescript
// High reliability (unstable network)
fullSnapshotInterval: 30, // Full snapshot every 30 frames
// Low bandwidth priority
fullSnapshotInterval: 120, // Full snapshot every 120 frames
```
### 3. Combine with AOI
```typescript
// Filter with AOI first, then delta compress
const filteredEntities = aoiSystem.filterSyncData(playerId, allEntities);
const syncData = { frame, timestamp, entities: filteredEntities };
const deltaData = compressor.compress(syncData);
```
### 4. Handle Entity Removal
```typescript
// Clean up compressor state when entity despawns
function onEntityDespawn(netId: number) {
compressor.removeEntity(netId);
}
```
## Integration with Other Features
```
┌─────────────────┐
│ Game State │
└────────┬────────┘
┌────────▼────────┐
│ AOI Filter │ ← Only process entities in view
└────────┬────────┘
┌────────▼────────┐
│ Delta Compress │ ← Only send changed fields
└────────┬────────┘
┌────────▼────────┐
│ Network Send │
└─────────────────┘
```
## Debugging
```typescript
const compressor = createStateDeltaCompressor();
// Check compression efficiency
const original = syncData;
const compressed = compressor.compress(original);
console.log('Original entities:', original.entities.length);
console.log('Compressed entities:', compressed.entities.length);
console.log('Is full snapshot:', compressed.isFullSnapshot);
// View each entity's changes
for (const delta of compressed.entities) {
console.log(`Entity ${delta.netId}:`, {
hasPosition: !!(delta.flags & DeltaFlags.POSITION),
hasRotation: !!(delta.flags & DeltaFlags.ROTATION),
hasVelocity: !!(delta.flags & DeltaFlags.VELOCITY),
hasCustom: !!(delta.flags & DeltaFlags.CUSTOM),
});
}
```

View File

@@ -147,7 +147,10 @@ service.on('chat', (data) => {
- [Client Usage](/en/modules/network/client/) - NetworkPlugin, components and systems
- [Server Side](/en/modules/network/server/) - GameServer and Room management
- [State Sync](/en/modules/network/sync/) - Interpolation, prediction and snapshots
- [State Sync](/en/modules/network/sync/) - Interpolation and snapshot buffering
- [Client Prediction](/en/modules/network/prediction/) - Input prediction and server reconciliation
- [Area of Interest (AOI)](/en/modules/network/aoi/) - View filtering and bandwidth optimization
- [Delta Compression](/en/modules/network/delta/) - State delta synchronization
- [API Reference](/en/modules/network/api/) - Complete API documentation
## Service Tokens
@@ -159,10 +162,14 @@ import {
NetworkServiceToken,
NetworkSyncSystemToken,
NetworkSpawnSystemToken,
NetworkInputSystemToken
NetworkInputSystemToken,
NetworkPredictionSystemToken,
NetworkAOISystemToken,
} from '@esengine/network';
const networkService = services.get(NetworkServiceToken);
const predictionSystem = services.get(NetworkPredictionSystemToken);
const aoiSystem = services.get(NetworkAOISystemToken);
```
## Blueprint Nodes

View File

@@ -0,0 +1,254 @@
---
title: "Client Prediction"
description: "Local input prediction and server reconciliation"
---
Client prediction is a key technique in networked games to reduce input latency. By immediately applying player inputs locally while waiting for server confirmation, games feel more responsive.
## NetworkPredictionSystem
`NetworkPredictionSystem` is an ECS system dedicated to handling local player prediction.
### Basic Usage
```typescript
import { NetworkPlugin } from '@esengine/network';
const networkPlugin = new NetworkPlugin({
enablePrediction: true,
predictionConfig: {
moveSpeed: 200, // Movement speed (units/second)
maxUnacknowledgedInputs: 60, // Max unacknowledged inputs
reconciliationThreshold: 0.5, // Reconciliation threshold
reconciliationSpeed: 10, // Reconciliation speed
}
});
await Core.installPlugin(networkPlugin);
```
### Setting Up Local Player
After the local player entity spawns, set its network ID:
```typescript
networkPlugin.registerPrefab('player', (scene, spawn) => {
const entity = scene.createEntity(`player_${spawn.netId}`);
const identity = entity.addComponent(new NetworkIdentity());
identity.netId = spawn.netId;
identity.ownerId = spawn.ownerId;
identity.bHasAuthority = spawn.ownerId === networkPlugin.localPlayerId;
identity.bIsLocalPlayer = identity.bHasAuthority;
entity.addComponent(new NetworkTransform());
// Set local player for prediction
if (identity.bIsLocalPlayer) {
networkPlugin.setLocalPlayerNetId(spawn.netId);
}
return entity;
});
```
### Sending Input
```typescript
// Send movement input in game loop
function onUpdate() {
const moveX = Input.getAxis('horizontal');
const moveY = Input.getAxis('vertical');
if (moveX !== 0 || moveY !== 0) {
networkPlugin.sendMoveInput(moveX, moveY);
}
// Send action input
if (Input.isPressed('attack')) {
networkPlugin.sendActionInput('attack');
}
}
```
## Prediction Configuration
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `moveSpeed` | `number` | 200 | Movement speed (units/second) |
| `enabled` | `boolean` | true | Whether prediction is enabled |
| `maxUnacknowledgedInputs` | `number` | 60 | Max unacknowledged inputs |
| `reconciliationThreshold` | `number` | 0.5 | Position difference threshold for reconciliation |
| `reconciliationSpeed` | `number` | 10 | Reconciliation smoothing speed |
## How It Works
```
Client Server
│ │
├─ 1. Capture input (seq=1) │
├─ 2. Predict movement locally │
├─ 3. Send input to server ─────────►
│ │
├─ 4. Continue capturing (seq=2,3...) │
├─ 5. Continue predicting │
│ │
│ ├─ 6. Process input (seq=1)
│ │
◄──────── 7. Return state (ackSeq=1) ─
│ │
├─ 8. Compare prediction with server │
├─ 9. Replay inputs seq=2,3... │
├─ 10. Smooth correction │
│ │
```
### Step by Step
1. **Input Capture**: Capture player input and assign sequence number
2. **Local Prediction**: Immediately apply input to local state
3. **Send Input**: Send input to server
4. **Cache Input**: Save input for later reconciliation
5. **Receive Acknowledgment**: Server returns authoritative state with ack sequence
6. **State Comparison**: Compare predicted state with server state
7. **Input Replay**: Recalculate state using cached unacknowledged inputs
8. **Smooth Correction**: Interpolate smoothly to correct position
## Low-Level API
For fine-grained control, use the `ClientPrediction` class directly:
```typescript
import { createClientPrediction, type IPredictor } from '@esengine/network';
// Define state type
interface PlayerState {
x: number;
y: number;
rotation: number;
}
// Define input type
interface PlayerInput {
dx: number;
dy: number;
}
// Define predictor
const predictor: IPredictor<PlayerState, PlayerInput> = {
predict(state: PlayerState, input: PlayerInput, dt: number): PlayerState {
return {
x: state.x + input.dx * MOVE_SPEED * dt,
y: state.y + input.dy * MOVE_SPEED * dt,
rotation: state.rotation,
};
}
};
// Create client prediction
const prediction = createClientPrediction(predictor, {
maxUnacknowledgedInputs: 60,
reconciliationThreshold: 0.5,
reconciliationSpeed: 10,
});
// Record input and get predicted state
const input = { dx: 1, dy: 0 };
const predictedState = prediction.recordInput(input, currentState, deltaTime);
// Get input to send
const inputToSend = prediction.getInputToSend();
// Reconcile with server state
prediction.reconcile(
serverState,
serverAckSeq,
(state) => ({ x: state.x, y: state.y }),
deltaTime
);
// Get correction offset
const offset = prediction.correctionOffset;
```
## Enable/Disable Prediction
```typescript
// Toggle prediction at runtime
networkPlugin.setPredictionEnabled(false);
// Check prediction status
if (networkPlugin.isPredictionEnabled) {
console.log('Prediction is active');
}
```
## Best Practices
### 1. Set Appropriate Reconciliation Threshold
```typescript
// Action games: lower threshold, more precise
predictionConfig: {
reconciliationThreshold: 0.1,
}
// Casual games: higher threshold, smoother
predictionConfig: {
reconciliationThreshold: 1.0,
}
```
### 2. Prediction Only for Local Player
Remote players should use interpolation, not prediction:
```typescript
const identity = entity.getComponent(NetworkIdentity);
if (identity.bIsLocalPlayer) {
// Use prediction system
} else {
// Use NetworkSyncSystem interpolation
}
```
### 3. Handle High Latency
```typescript
// High latency network: increase buffer
predictionConfig: {
maxUnacknowledgedInputs: 120, // Increase buffer
reconciliationSpeed: 5, // Slower correction
}
```
### 4. Deterministic Prediction
Ensure client and server use the same physics calculations:
```typescript
// Use fixed timestep
const FIXED_DT = 1 / 60;
function applyInput(state: PlayerState, input: PlayerInput): PlayerState {
// Use fixed timestep instead of actual deltaTime
return {
x: state.x + input.dx * MOVE_SPEED * FIXED_DT,
y: state.y + input.dy * MOVE_SPEED * FIXED_DT,
rotation: state.rotation,
};
}
```
## Debugging
```typescript
// Get prediction system instance
const predictionSystem = networkPlugin.predictionSystem;
if (predictionSystem) {
console.log('Pending inputs:', predictionSystem.pendingInputCount);
console.log('Current sequence:', predictionSystem.inputSequence);
}
```

View File

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

View File

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

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

View 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();
```

View File

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

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

View 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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ ESEngine 提供了丰富的功能模块,可以按需引入到你的项目中
|------|------|------|
| [可视化脚本](/modules/blueprint/) | `@esengine/blueprint` | 蓝图可视化脚本系统 |
| [程序化生成](/modules/procgen/) | `@esengine/procgen` | 噪声函数、随机工具 |
| [世界流式加载](/modules/world-streaming/) | `@esengine/world-streaming` | 开放世界区块流式加载 |
### 网络模块

View File

@@ -0,0 +1,283 @@
---
title: "兴趣区域管理 (AOI)"
description: "基于视野范围的网络实体过滤"
---
AOIArea of Interest兴趣区域是大规模多人游戏中用于优化网络带宽的关键技术。通过只同步玩家视野范围内的实体可以大幅减少网络流量。
## NetworkAOISystem
`NetworkAOISystem` 提供基于网格的兴趣区域管理。
### 启用 AOI
```typescript
import { NetworkPlugin } from '@esengine/network';
const networkPlugin = new NetworkPlugin({
enableAOI: true,
aoiConfig: {
cellSize: 100, // 网格单元大小
defaultViewRange: 500, // 默认视野范围
enabled: true,
}
});
await Core.installPlugin(networkPlugin);
```
### 添加观察者
每个需要接收同步数据的玩家都需要作为观察者添加:
```typescript
// 玩家加入时添加观察者
networkPlugin.registerPrefab('player', (scene, spawn) => {
const entity = scene.createEntity(`player_${spawn.netId}`);
// ... 设置组件
// 将玩家添加为 AOI 观察者
networkPlugin.addAOIObserver(
spawn.netId, // 网络 ID
spawn.pos.x, // 初始 X 位置
spawn.pos.y, // 初始 Y 位置
600 // 视野范围(可选)
);
return entity;
});
// 玩家离开时移除观察者
networkPlugin.removeAOIObserver(playerNetId);
```
### 更新观察者位置
当玩家移动时,需要更新其 AOI 位置:
```typescript
// 在游戏循环或同步回调中更新
networkPlugin.updateAOIObserverPosition(playerNetId, newX, newY);
```
## AOI 配置
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `cellSize` | `number` | 100 | 网格单元大小 |
| `defaultViewRange` | `number` | 500 | 默认视野范围 |
| `enabled` | `boolean` | true | 是否启用 AOI |
### 网格大小建议
网格大小应根据游戏视野范围设置:
```typescript
// 建议cellSize = defaultViewRange / 3 到 / 5
aoiConfig: {
cellSize: 100,
defaultViewRange: 500, // 网格大约是视野的 1/5
}
```
## 查询接口
### 获取可见实体
```typescript
// 获取玩家能看到的所有实体
const visibleEntities = networkPlugin.getVisibleEntities(playerNetId);
console.log('Visible entities:', visibleEntities);
```
### 检查可见性
```typescript
// 检查玩家是否能看到某个实体
if (networkPlugin.canSee(playerNetId, targetEntityNetId)) {
// 目标在视野内
}
```
## 事件监听
AOI 系统会在实体进入/离开视野时触发事件:
```typescript
const aoiSystem = networkPlugin.aoiSystem;
if (aoiSystem) {
aoiSystem.addListener((event) => {
if (event.type === 'enter') {
console.log(`Entity ${event.targetNetId} entered view of ${event.observerNetId}`);
// 可以在这里发送实体的初始状态
} else if (event.type === 'exit') {
console.log(`Entity ${event.targetNetId} left view of ${event.observerNetId}`);
// 可以在这里清理资源
}
});
}
```
## 服务器端过滤
AOI 最常用于服务器端,过滤发送给每个客户端的同步数据:
```typescript
// 服务器端示例
import { NetworkAOISystem, createNetworkAOISystem } from '@esengine/network';
class GameServer {
private aoiSystem = createNetworkAOISystem({
cellSize: 100,
defaultViewRange: 500,
});
// 玩家加入
onPlayerJoin(playerId: number, x: number, y: number) {
this.aoiSystem.addObserver(playerId, x, y);
}
// 玩家移动
onPlayerMove(playerId: number, x: number, y: number) {
this.aoiSystem.updateObserverPosition(playerId, x, y);
}
// 发送同步数据
broadcastSync(allEntities: EntitySyncState[]) {
for (const playerId of this.players) {
// 使用 AOI 过滤
const filteredEntities = this.aoiSystem.filterSyncData(
playerId,
allEntities
);
// 只发送可见实体
this.sendToPlayer(playerId, { entities: filteredEntities });
}
}
}
```
## 工作原理
```
┌─────────────────────────────────────────────────────────────┐
│ 游戏世界 │
│ ┌─────┬─────┬─────┬─────┬─────┐ │
│ │ │ │ E │ │ │ │
│ ├─────┼─────┼─────┼─────┼─────┤ E = 敌人实体 │
│ │ │ P │ ● │ │ │ P = 玩家 │
│ ├─────┼─────┼─────┼─────┼─────┤ ● = 玩家视野中心 │
│ │ │ │ E │ E │ │ ○ = 视野范围 │
│ ├─────┼─────┼─────┼─────┼─────┤ │
│ │ │ │ │ │ E │ 玩家只能看到视野内的 E │
│ └─────┴─────┴─────┴─────┴─────┘ │
│ │
│ 视野范围(圆形):包含 3 个敌人 │
│ 网格优化:只检查视野覆盖的网格单元 │
└─────────────────────────────────────────────────────────────┘
```
### 网格优化
AOI 使用空间网格加速查询:
1. **添加实体**:根据位置计算所在网格
2. **视野检测**:只检查视野范围覆盖的网格
3. **移动更新**:跨网格时更新网格归属
4. **事件触发**:检测进入/离开视野
## 动态视野范围
可以为不同类型的玩家设置不同的视野:
```typescript
// 普通玩家
networkPlugin.addAOIObserver(playerId, x, y, 500);
// VIP 玩家(更大视野)
networkPlugin.addAOIObserver(vipPlayerId, x, y, 800);
// 运行时调整视野
const aoiSystem = networkPlugin.aoiSystem;
if (aoiSystem) {
aoiSystem.updateObserverViewRange(playerId, 600);
}
```
## 最佳实践
### 1. 服务器端使用
AOI 过滤应在服务器端进行,客户端不应信任自己的 AOI 判断:
```typescript
// 服务器端过滤后再发送
const filtered = aoiSystem.filterSyncData(playerId, entities);
sendToClient(playerId, filtered);
```
### 2. 边界处理
在视野边缘添加缓冲区防止闪烁:
```typescript
// 进入视野时立即添加
// 离开视野时延迟移除(保持额外 1-2 秒)
aoiSystem.addListener((event) => {
if (event.type === 'exit') {
setTimeout(() => {
// 再次检查是否真的离开
if (!aoiSystem.canSee(event.observerNetId, event.targetNetId)) {
removeFromClient(event.observerNetId, event.targetNetId);
}
}, 1000);
}
});
```
### 3. 大型实体
对于大型实体(如 Boss可能需要特殊处理
```typescript
// Boss 总是对所有人可见
function filterWithBoss(playerId: number, entities: EntitySyncState[]) {
const filtered = aoiSystem.filterSyncData(playerId, entities);
// 添加 Boss 实体
const bossState = entities.find(e => e.netId === bossNetId);
if (bossState && !filtered.includes(bossState)) {
filtered.push(bossState);
}
return filtered;
}
```
### 4. 性能考虑
```typescript
// 大规模游戏建议配置
aoiConfig: {
cellSize: 200, // 较大的网格减少网格数量
defaultViewRange: 800, // 根据实际视野设置
}
```
## 调试
```typescript
const aoiSystem = networkPlugin.aoiSystem;
if (aoiSystem) {
console.log('AOI enabled:', aoiSystem.enabled);
console.log('Observer count:', aoiSystem.observerCount);
// 获取特定玩家的可见实体
const visible = aoiSystem.getVisibleEntities(playerId);
console.log('Visible entities:', visible.length);
}
```

View File

@@ -0,0 +1,316 @@
---
title: "状态增量压缩"
description: "减少网络带宽的增量同步"
---
状态增量压缩通过只发送变化的字段来减少网络带宽。对于频繁同步的游戏状态,这可以显著降低数据传输量。
## StateDeltaCompressor
`StateDeltaCompressor` 类用于压缩和解压状态增量。
### 基本用法
```typescript
import { createStateDeltaCompressor, type SyncData } from '@esengine/network';
// 创建压缩器
const compressor = createStateDeltaCompressor({
positionThreshold: 0.01, // 位置变化阈值
rotationThreshold: 0.001, // 旋转变化阈值(弧度)
velocityThreshold: 0.1, // 速度变化阈值
fullSnapshotInterval: 60, // 完整快照间隔(帧数)
});
// 压缩同步数据
const syncData: SyncData = {
frame: 100,
timestamp: Date.now(),
entities: [
{ netId: 1, pos: { x: 100, y: 200 }, rot: 0 },
{ netId: 2, pos: { x: 300, y: 400 }, rot: 1.5 },
],
};
const deltaData = compressor.compress(syncData);
// deltaData 只包含变化的字段
// 解压增量数据
const fullData = compressor.decompress(deltaData);
```
## 配置选项
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `positionThreshold` | `number` | 0.01 | 位置变化阈值 |
| `rotationThreshold` | `number` | 0.001 | 旋转变化阈值(弧度) |
| `velocityThreshold` | `number` | 0.1 | 速度变化阈值 |
| `fullSnapshotInterval` | `number` | 60 | 完整快照间隔(帧数) |
## 增量标志
使用位标志表示哪些字段发生了变化:
```typescript
import { DeltaFlags } from '@esengine/network';
// 位标志定义
DeltaFlags.NONE // 0 - 无变化
DeltaFlags.POSITION // 1 - 位置变化
DeltaFlags.ROTATION // 2 - 旋转变化
DeltaFlags.VELOCITY // 4 - 速度变化
DeltaFlags.ANGULAR_VELOCITY // 8 - 角速度变化
DeltaFlags.CUSTOM // 16 - 自定义数据变化
```
## 数据格式
### 完整状态
```typescript
interface EntitySyncState {
netId: number;
pos?: { x: number; y: number };
rot?: number;
vel?: { x: number; y: number };
angVel?: number;
custom?: Record<string, unknown>;
}
```
### 增量状态
```typescript
interface EntityDeltaState {
netId: number;
flags: number; // 变化标志位
pos?: { x: number; y: number }; // 仅在 POSITION 标志时存在
rot?: number; // 仅在 ROTATION 标志时存在
vel?: { x: number; y: number }; // 仅在 VELOCITY 标志时存在
angVel?: number; // 仅在 ANGULAR_VELOCITY 标志时存在
custom?: Record<string, unknown>; // 仅在 CUSTOM 标志时存在
}
```
## 工作原理
```
帧 1 (完整快照)
Entity 1: pos=(100, 200), rot=0
帧 2 (增量)
Entity 1: flags=POSITION, pos=(101, 200) // 只有 X 变化
帧 3 (增量)
Entity 1: flags=0 // 无变化,不发送
帧 4 (增量)
Entity 1: flags=POSITION|ROTATION, pos=(105, 200), rot=0.5
帧 60 (强制完整快照)
Entity 1: pos=(200, 300), rot=1.0, vel=(5, 0)
```
## 服务器端使用
```typescript
import { createStateDeltaCompressor } from '@esengine/network';
class GameServer {
private compressor = createStateDeltaCompressor();
// 广播状态更新
broadcastState(entities: EntitySyncState[]) {
const syncData: SyncData = {
frame: this.currentFrame,
timestamp: Date.now(),
entities,
};
// 压缩数据
const deltaData = this.compressor.compress(syncData);
// 发送增量数据
this.broadcast('sync', deltaData);
}
// 玩家离开时清理
onPlayerLeave(netId: number) {
this.compressor.removeEntity(netId);
}
}
```
## 客户端使用
```typescript
class GameClient {
private compressor = createStateDeltaCompressor();
// 接收增量数据
onSyncReceived(deltaData: DeltaSyncData) {
// 解压为完整状态
const fullData = this.compressor.decompress(deltaData);
// 应用状态
for (const entity of fullData.entities) {
this.applyEntityState(entity);
}
}
}
```
## 带宽节省示例
假设每个实体有以下数据:
| 字段 | 大小(字节) |
|------|------------|
| netId | 4 |
| pos.x | 8 |
| pos.y | 8 |
| rot | 8 |
| vel.x | 8 |
| vel.y | 8 |
| angVel | 8 |
| **总计** | **52** |
使用增量压缩:
| 场景 | 原始 | 压缩后 | 节省 |
|------|------|--------|------|
| 只有位置变化 | 52 | 4+1+16 = 21 | 60% |
| 只有旋转变化 | 52 | 4+1+8 = 13 | 75% |
| 静止不动 | 52 | 0 | 100% |
| 位置+旋转变化 | 52 | 4+1+24 = 29 | 44% |
## 强制完整快照
某些情况下需要发送完整快照:
```typescript
// 新玩家加入时
compressor.forceFullSnapshot();
const data = compressor.compress(syncData);
// 这次会发送完整状态
// 重连时
compressor.clear(); // 清除历史状态
compressor.forceFullSnapshot();
```
## 自定义数据
支持同步自定义游戏数据:
```typescript
const syncData: SyncData = {
frame: 100,
timestamp: Date.now(),
entities: [
{
netId: 1,
pos: { x: 100, y: 200 },
custom: {
health: 80,
mana: 50,
buffs: ['speed', 'shield'],
},
},
],
};
// 自定义数据也会进行增量压缩
const deltaData = compressor.compress(syncData);
```
## 最佳实践
### 1. 合理设置阈值
```typescript
// 高精度游戏(如竞技游戏)
const compressor = createStateDeltaCompressor({
positionThreshold: 0.001,
rotationThreshold: 0.0001,
});
// 普通游戏
const compressor = createStateDeltaCompressor({
positionThreshold: 0.1,
rotationThreshold: 0.01,
});
```
### 2. 调整完整快照间隔
```typescript
// 高可靠性(网络不稳定)
fullSnapshotInterval: 30, // 每 30 帧发送完整快照
// 低带宽优先
fullSnapshotInterval: 120, // 每 120 帧发送完整快照
```
### 3. 配合 AOI 使用
```typescript
// 先用 AOI 过滤,再用增量压缩
const filteredEntities = aoiSystem.filterSyncData(playerId, allEntities);
const syncData = { frame, timestamp, entities: filteredEntities };
const deltaData = compressor.compress(syncData);
```
### 4. 处理实体移除
```typescript
// 实体销毁时清理压缩器状态
function onEntityDespawn(netId: number) {
compressor.removeEntity(netId);
}
```
## 与其他功能配合
```
┌─────────────────┐
│ 游戏状态 │
└────────┬────────┘
┌────────▼────────┐
│ AOI 过滤 │ ← 只处理视野内实体
└────────┬────────┘
┌────────▼────────┐
│ 增量压缩 │ ← 只发送变化的字段
└────────┬────────┘
┌────────▼────────┐
│ 网络传输 │
└─────────────────┘
```
## 调试
```typescript
const compressor = createStateDeltaCompressor();
// 检查压缩效果
const original = syncData;
const compressed = compressor.compress(original);
console.log('Original entities:', original.entities.length);
console.log('Compressed entities:', compressed.entities.length);
console.log('Is full snapshot:', compressed.isFullSnapshot);
// 查看每个实体的变化
for (const delta of compressed.entities) {
console.log(`Entity ${delta.netId}:`, {
hasPosition: !!(delta.flags & DeltaFlags.POSITION),
hasRotation: !!(delta.flags & DeltaFlags.ROTATION),
hasVelocity: !!(delta.flags & DeltaFlags.VELOCITY),
hasCustom: !!(delta.flags & DeltaFlags.CUSTOM),
});
}
```

View File

@@ -147,7 +147,10 @@ service.on('chat', (data) => {
- [客户端使用](/modules/network/client/) - NetworkPlugin、组件和系统
- [服务器端](/modules/network/server/) - GameServer 和 Room 管理
- [状态同步](/modules/network/sync/) - 插值、预测和快照
- [状态同步](/modules/network/sync/) - 插值和快照缓冲
- [客户端预测](/modules/network/prediction/) - 输入预测和服务器校正
- [兴趣区域 (AOI)](/modules/network/aoi/) - 视野过滤和带宽优化
- [增量压缩](/modules/network/delta/) - 状态增量同步
- [API 参考](/modules/network/api/) - 完整 API 文档
## 服务令牌
@@ -159,10 +162,14 @@ import {
NetworkServiceToken,
NetworkSyncSystemToken,
NetworkSpawnSystemToken,
NetworkInputSystemToken
NetworkInputSystemToken,
NetworkPredictionSystemToken,
NetworkAOISystemToken,
} from '@esengine/network';
const networkService = services.get(NetworkServiceToken);
const predictionSystem = services.get(NetworkPredictionSystemToken);
const aoiSystem = services.get(NetworkAOISystemToken);
```
## 蓝图节点

View File

@@ -0,0 +1,254 @@
---
title: "客户端预测"
description: "本地输入预测和服务器校正"
---
客户端预测是网络游戏中用于减少输入延迟的关键技术。通过在本地立即应用玩家输入,同时等待服务器确认,可以让游戏感觉更加流畅响应。
## NetworkPredictionSystem
`NetworkPredictionSystem` 是专门处理本地玩家预测的 ECS 系统。
### 基本用法
```typescript
import { NetworkPlugin } from '@esengine/network';
const networkPlugin = new NetworkPlugin({
enablePrediction: true,
predictionConfig: {
moveSpeed: 200, // 移动速度(单位/秒)
maxUnacknowledgedInputs: 60, // 最大未确认输入数
reconciliationThreshold: 0.5, // 校正阈值
reconciliationSpeed: 10, // 校正速度
}
});
await Core.installPlugin(networkPlugin);
```
### 设置本地玩家
当本地玩家实体生成后,需要设置其网络 ID
```typescript
networkPlugin.registerPrefab('player', (scene, spawn) => {
const entity = scene.createEntity(`player_${spawn.netId}`);
const identity = entity.addComponent(new NetworkIdentity());
identity.netId = spawn.netId;
identity.ownerId = spawn.ownerId;
identity.bHasAuthority = spawn.ownerId === networkPlugin.localPlayerId;
identity.bIsLocalPlayer = identity.bHasAuthority;
entity.addComponent(new NetworkTransform());
// 设置本地玩家用于预测
if (identity.bIsLocalPlayer) {
networkPlugin.setLocalPlayerNetId(spawn.netId);
}
return entity;
});
```
### 发送输入
```typescript
// 在游戏循环中发送移动输入
function onUpdate() {
const moveX = Input.getAxis('horizontal');
const moveY = Input.getAxis('vertical');
if (moveX !== 0 || moveY !== 0) {
networkPlugin.sendMoveInput(moveX, moveY);
}
// 发送动作输入
if (Input.isPressed('attack')) {
networkPlugin.sendActionInput('attack');
}
}
```
## 预测配置
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `moveSpeed` | `number` | 200 | 移动速度(单位/秒) |
| `enabled` | `boolean` | true | 是否启用预测 |
| `maxUnacknowledgedInputs` | `number` | 60 | 最大未确认输入数 |
| `reconciliationThreshold` | `number` | 0.5 | 触发校正的位置差异阈值 |
| `reconciliationSpeed` | `number` | 10 | 校正平滑速度 |
## 工作原理
```
客户端 服务器
│ │
├─ 1. 捕获输入 (seq=1) │
├─ 2. 本地预测移动 │
├─ 3. 发送输入到服务器 ──────────────►
│ │
├─ 4. 继续捕获输入 (seq=2,3...) │
├─ 5. 继续本地预测 │
│ │
│ ├─ 6. 处理输入 (seq=1)
│ │
◄──────── 7. 返回状态 (ackSeq=1) ────
│ │
├─ 8. 比较预测和服务器状态 │
├─ 9. 重放 seq=2,3... 的输入 │
├─ 10. 平滑校正到正确位置 │
│ │
```
### 步骤详解
1. **输入捕获**:捕获玩家输入并分配序列号
2. **本地预测**:立即应用输入到本地状态
3. **发送输入**:将输入发送到服务器
4. **缓存输入**:保存输入用于后续校正
5. **接收确认**:服务器返回权威状态和已确认序列号
6. **状态比较**:比较预测状态和服务器状态
7. **输入重放**:使用缓存的未确认输入重新计算状态
8. **平滑校正**:平滑插值到正确位置
## 底层 API
如果需要更细粒度的控制,可以直接使用 `ClientPrediction` 类:
```typescript
import { createClientPrediction, type IPredictor } from '@esengine/network';
// 定义状态类型
interface PlayerState {
x: number;
y: number;
rotation: number;
}
// 定义输入类型
interface PlayerInput {
dx: number;
dy: number;
}
// 定义预测器
const predictor: IPredictor<PlayerState, PlayerInput> = {
predict(state: PlayerState, input: PlayerInput, dt: number): PlayerState {
return {
x: state.x + input.dx * MOVE_SPEED * dt,
y: state.y + input.dy * MOVE_SPEED * dt,
rotation: state.rotation,
};
}
};
// 创建客户端预测
const prediction = createClientPrediction(predictor, {
maxUnacknowledgedInputs: 60,
reconciliationThreshold: 0.5,
reconciliationSpeed: 10,
});
// 记录输入并获取预测状态
const input = { dx: 1, dy: 0 };
const predictedState = prediction.recordInput(input, currentState, deltaTime);
// 获取要发送的输入
const inputToSend = prediction.getInputToSend();
// 与服务器状态校正
prediction.reconcile(
serverState,
serverAckSeq,
(state) => ({ x: state.x, y: state.y }),
deltaTime
);
// 获取校正偏移
const offset = prediction.correctionOffset;
```
## 启用/禁用预测
```typescript
// 运行时切换预测
networkPlugin.setPredictionEnabled(false);
// 检查预测状态
if (networkPlugin.isPredictionEnabled) {
console.log('Prediction is active');
}
```
## 最佳实践
### 1. 合理设置校正阈值
```typescript
// 动作游戏:较低阈值,更精确
predictionConfig: {
reconciliationThreshold: 0.1,
}
// 休闲游戏:较高阈值,更平滑
predictionConfig: {
reconciliationThreshold: 1.0,
}
```
### 2. 预测仅用于本地玩家
远程玩家应使用插值而非预测:
```typescript
const identity = entity.getComponent(NetworkIdentity);
if (identity.bIsLocalPlayer) {
// 使用预测系统
} else {
// 使用 NetworkSyncSystem 的插值
}
```
### 3. 处理高延迟
```typescript
// 高延迟网络增加缓冲
predictionConfig: {
maxUnacknowledgedInputs: 120, // 增加缓冲
reconciliationSpeed: 5, // 减慢校正速度
}
```
### 4. 确定性预测
确保客户端和服务器使用相同的物理计算:
```typescript
// 使用固定时间步长
const FIXED_DT = 1 / 60;
function applyInput(state: PlayerState, input: PlayerInput): PlayerState {
// 使用固定时间步长而非实际 deltaTime
return {
x: state.x + input.dx * MOVE_SPEED * FIXED_DT,
y: state.y + input.dy * MOVE_SPEED * FIXED_DT,
rotation: state.rotation,
};
}
```
## 调试
```typescript
// 获取预测系统实例
const predictionSystem = networkPlugin.predictionSystem;
if (predictionSystem) {
console.log('Pending inputs:', predictionSystem.pendingInputCount);
console.log('Current sequence:', predictionSystem.inputSequence);
}
```

View File

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

View File

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

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

View 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();
```

View 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();
```

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

View 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 区块

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

View File

@@ -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) => {
// 清理 - 保存状态,释放资源
}
});
```

View File

@@ -1,5 +1,47 @@
# @esengine/network
## 2.2.0
### Minor Changes
- [#379](https://github.com/esengine/esengine/pull/379) [`fb8bde6`](https://github.com/esengine/esengine/commit/fb8bde64856ef71ea8e20906496682ccfb27f9b3) Thanks [@esengine](https://github.com/esengine)! - feat(network): 网络模块增强
### 新增功能
- **客户端预测 (NetworkPredictionSystem)**
- 本地输入预测和服务器校正
- 平滑的校正偏移应用
- 可配置移动速度、校正阈值等
- **兴趣区域管理 (NetworkAOISystem)**
- 基于网格的 AOI 实现
- 观察者进入/离开事件
- 同步数据过滤
- **状态增量压缩 (StateDeltaCompressor)**
- 只发送变化的字段
- 可配置变化阈值
- 定期完整快照
- **断线重连**
- 自动重连机制
- Token 认证
- 完整状态恢复
### 协议增强
- 添加输入序列号和时间戳
- 添加速度和角速度字段
- 添加自定义数据字段
- 新增重连协议
### 文档
- 添加客户端预测文档(中英文)
- 添加 AOI 文档(中英文)
- 添加增量压缩文档(中英文)
## 2.1.1
### Patch Changes
- Updated dependencies [[`a000cc0`](https://github.com/esengine/esengine/commit/a000cc07d7cebe8ccbfa983fde610296bfba2f1b)]:
- @esengine/rpc@1.1.1
## 2.1.0
### Minor Changes

View File

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

View File

@@ -1,13 +1,126 @@
/**
* @zh 网络插件
* @en Network Plugin
*
* @zh 提供基于 @esengine/rpc 的网络同步功能,支持客户端预测和断线重连
* @en Provides @esengine/rpc based network synchronization with client prediction and reconnection
*/
import { type IPlugin, Core, type ServiceContainer, type Scene } from '@esengine/ecs-framework'
import { GameNetworkService, type NetworkServiceOptions } from './services/NetworkService'
import { NetworkSyncSystem } from './systems/NetworkSyncSystem'
import {
GameNetworkService,
type NetworkServiceOptions,
NetworkState,
} from './services/NetworkService'
import { NetworkSyncSystem, type NetworkSyncConfig } from './systems/NetworkSyncSystem'
import { NetworkSpawnSystem, type PrefabFactory } from './systems/NetworkSpawnSystem'
import { NetworkInputSystem } from './systems/NetworkInputSystem'
import { NetworkInputSystem, type NetworkInputConfig } from './systems/NetworkInputSystem'
import {
NetworkPredictionSystem,
type NetworkPredictionConfig,
} from './systems/NetworkPredictionSystem'
import {
NetworkAOISystem,
type NetworkAOIConfig,
} from './systems/NetworkAOISystem'
import type { FullStateData, SyncData } from './protocol'
// =============================================================================
// Types | 类型定义
// =============================================================================
/**
* @zh 网络插件配置
* @en Network plugin configuration
*/
export interface NetworkPluginConfig {
/**
* @zh 是否启用客户端预测
* @en Whether to enable client prediction
*/
enablePrediction: boolean
/**
* @zh 是否启用自动重连
* @en Whether to enable auto reconnection
*/
enableAutoReconnect: boolean
/**
* @zh 重连最大尝试次数
* @en Maximum reconnection attempts
*/
maxReconnectAttempts: number
/**
* @zh 重连间隔(毫秒)
* @en Reconnection interval in milliseconds
*/
reconnectInterval: number
/**
* @zh 同步系统配置
* @en Sync system configuration
*/
syncConfig?: Partial<NetworkSyncConfig>
/**
* @zh 输入系统配置
* @en Input system configuration
*/
inputConfig?: Partial<NetworkInputConfig>
/**
* @zh 预测系统配置
* @en Prediction system configuration
*/
predictionConfig?: Partial<NetworkPredictionConfig>
/**
* @zh 是否启用 AOI 兴趣管理
* @en Whether to enable AOI interest management
*/
enableAOI: boolean
/**
* @zh AOI 系统配置
* @en AOI system configuration
*/
aoiConfig?: Partial<NetworkAOIConfig>
}
const DEFAULT_CONFIG: NetworkPluginConfig = {
enablePrediction: true,
enableAutoReconnect: true,
maxReconnectAttempts: 5,
reconnectInterval: 2000,
enableAOI: false,
}
/**
* @zh 连接选项
* @en Connection options
*/
export interface ConnectOptions extends NetworkServiceOptions {
playerName: string
roomId?: string
}
/**
* @zh 重连状态
* @en Reconnection state
*/
interface ReconnectState {
token: string
playerId: number
roomId: string
attempts: number
isReconnecting: boolean
}
// =============================================================================
// NetworkPlugin | 网络插件
// =============================================================================
/**
* @zh 网络插件
@@ -21,7 +134,10 @@ import { NetworkInputSystem } from './systems/NetworkInputSystem'
* import { Core } from '@esengine/ecs-framework'
* import { NetworkPlugin } from '@esengine/network'
*
* const networkPlugin = new NetworkPlugin()
* const networkPlugin = new NetworkPlugin({
* enablePrediction: true,
* enableAutoReconnect: true
* })
* await Core.installPlugin(networkPlugin)
*
* // 连接到服务器
@@ -36,13 +152,28 @@ import { NetworkInputSystem } from './systems/NetworkInputSystem'
*/
export class NetworkPlugin implements IPlugin {
public readonly name = '@esengine/network'
public readonly version = '2.0.0'
public readonly version = '2.1.0'
private readonly _config: NetworkPluginConfig
private _networkService!: GameNetworkService
private _syncSystem!: NetworkSyncSystem
private _spawnSystem!: NetworkSpawnSystem
private _inputSystem!: NetworkInputSystem
private _predictionSystem: NetworkPredictionSystem | null = null
private _aoiSystem: NetworkAOISystem | null = null
private _localPlayerId: number = 0
private _reconnectState: ReconnectState | null = null
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null
private _lastConnectOptions: ConnectOptions | null = null
constructor(config?: Partial<NetworkPluginConfig>) {
this._config = { ...DEFAULT_CONFIG, ...config }
}
// =========================================================================
// Getters | 属性访问器
// =========================================================================
/**
* @zh 网络服务
@@ -76,6 +207,22 @@ export class NetworkPlugin implements IPlugin {
return this._inputSystem
}
/**
* @zh 预测系统
* @en Prediction system
*/
get predictionSystem(): NetworkPredictionSystem | null {
return this._predictionSystem
}
/**
* @zh AOI 系统
* @en AOI system
*/
get aoiSystem(): NetworkAOISystem | null {
return this._aoiSystem
}
/**
* @zh 本地玩家 ID
* @en Local player ID
@@ -92,6 +239,34 @@ export class NetworkPlugin implements IPlugin {
return this._networkService?.isConnected ?? false
}
/**
* @zh 是否正在重连
* @en Is reconnecting
*/
get isReconnecting(): boolean {
return this._reconnectState?.isReconnecting ?? false
}
/**
* @zh 是否启用预测
* @en Is prediction enabled
*/
get isPredictionEnabled(): boolean {
return this._config.enablePrediction && this._predictionSystem !== null
}
/**
* @zh 是否启用 AOI
* @en Is AOI enabled
*/
get isAOIEnabled(): boolean {
return this._config.enableAOI && this._aoiSystem !== null
}
// =========================================================================
// Plugin Lifecycle | 插件生命周期
// =========================================================================
/**
* @zh 安装插件
* @en Install plugin
@@ -110,13 +285,28 @@ export class NetworkPlugin implements IPlugin {
* @en Uninstall plugin
*/
uninstall(): void {
this._clearReconnectTimer()
this._networkService?.disconnect()
}
private _setupSystems(scene: Scene): void {
this._syncSystem = new NetworkSyncSystem()
// Create systems
this._syncSystem = new NetworkSyncSystem(this._config.syncConfig)
this._spawnSystem = new NetworkSpawnSystem(this._syncSystem)
this._inputSystem = new NetworkInputSystem(this._networkService)
this._inputSystem = new NetworkInputSystem(this._networkService, this._config.inputConfig)
// Create prediction system if enabled
if (this._config.enablePrediction) {
this._predictionSystem = new NetworkPredictionSystem(this._config.predictionConfig)
this._inputSystem.setPredictionSystem(this._predictionSystem)
scene.addSystem(this._predictionSystem)
}
// Create AOI system if enabled
if (this._config.enableAOI) {
this._aoiSystem = new NetworkAOISystem(this._config.aoiConfig)
scene.addSystem(this._aoiSystem)
}
scene.addSystem(this._syncSystem)
scene.addSystem(this._spawnSystem)
@@ -127,8 +317,14 @@ export class NetworkPlugin implements IPlugin {
private _setupMessageHandlers(): void {
this._networkService
.onSync((data) => {
this._syncSystem.handleSync({ entities: data.entities })
.onSync((data: SyncData) => {
// Use new sync handler with timestamps
this._syncSystem.handleSyncData(data)
// Reconcile prediction if enabled
if (this._predictionSystem) {
this._predictionSystem.reconcileWithServer(data)
}
})
.onSpawn((data) => {
this._spawnSystem.handleSpawn(data)
@@ -136,14 +332,32 @@ export class NetworkPlugin implements IPlugin {
.onDespawn((data) => {
this._spawnSystem.handleDespawn(data)
})
// Handle full state for reconnection
this._networkService.on('fullState', (data: FullStateData) => {
this._handleFullState(data)
})
}
// =========================================================================
// Connection | 连接管理
// =========================================================================
/**
* @zh 连接到服务器
* @en Connect to server
*/
public async connect(options: NetworkServiceOptions & { playerName: string; roomId?: string }): Promise<boolean> {
public async connect(options: ConnectOptions): Promise<boolean> {
this._lastConnectOptions = options
try {
// Setup disconnect handler for auto-reconnect
const originalOnDisconnect = options.onDisconnect
options.onDisconnect = (reason) => {
originalOnDisconnect?.(reason)
this._handleDisconnect(reason)
}
await this._networkService.connect(options)
const result = await this._networkService.call('join', {
@@ -154,8 +368,25 @@ export class NetworkPlugin implements IPlugin {
this._localPlayerId = result.playerId
this._spawnSystem.setLocalPlayerId(this._localPlayerId)
// Setup prediction for local player
if (this._predictionSystem) {
// Will be set when local player entity is spawned
}
// Save reconnect state
if (this._config.enableAutoReconnect) {
this._reconnectState = {
token: this._generateReconnectToken(),
playerId: result.playerId,
roomId: result.roomId,
attempts: 0,
isReconnecting: false,
}
}
return true
} catch (err) {
console.error('[NetworkPlugin] Connection failed:', err)
return false
}
}
@@ -165,14 +396,114 @@ export class NetworkPlugin implements IPlugin {
* @en Disconnect
*/
public async disconnect(): Promise<void> {
this._clearReconnectTimer()
this._reconnectState = null
try {
await this._networkService.call('leave', undefined)
} catch {
// ignore
}
this._networkService.disconnect()
this._cleanup()
}
private _handleDisconnect(reason?: string): void {
console.log('[NetworkPlugin] Disconnected:', reason)
if (this._config.enableAutoReconnect && this._reconnectState && !this._reconnectState.isReconnecting) {
this._attemptReconnect()
}
}
private _attemptReconnect(): void {
if (!this._reconnectState || !this._lastConnectOptions) return
if (this._reconnectState.attempts >= this._config.maxReconnectAttempts) {
console.error('[NetworkPlugin] Max reconnection attempts reached')
this._reconnectState = null
return
}
this._reconnectState.isReconnecting = true
this._reconnectState.attempts++
console.log(`[NetworkPlugin] Attempting reconnection (${this._reconnectState.attempts}/${this._config.maxReconnectAttempts})`)
this._reconnectTimer = setTimeout(async () => {
try {
await this._networkService.connect(this._lastConnectOptions!)
const result = await this._networkService.call('reconnect', {
playerId: this._reconnectState!.playerId,
roomId: this._reconnectState!.roomId,
token: this._reconnectState!.token,
})
if (result.success) {
console.log('[NetworkPlugin] Reconnection successful')
this._reconnectState!.isReconnecting = false
this._reconnectState!.attempts = 0
// Restore state
if (result.state) {
this._handleFullState(result.state)
}
} else {
console.error('[NetworkPlugin] Reconnection rejected:', result.error)
this._attemptReconnect()
}
} catch (err) {
console.error('[NetworkPlugin] Reconnection failed:', err)
if (this._reconnectState) {
this._reconnectState.isReconnecting = false
}
this._attemptReconnect()
}
}, this._config.reconnectInterval)
}
private _handleFullState(data: FullStateData): void {
// Clear existing entities
this._syncSystem.clearSnapshots()
// Spawn all entities from full state
for (const entityData of data.entities) {
this._spawnSystem.handleSpawn(entityData)
// Apply initial state if available
if (entityData.state) {
this._syncSystem.handleSyncData({
frame: data.frame,
timestamp: data.timestamp,
entities: [entityData.state],
})
}
}
}
private _clearReconnectTimer(): void {
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer)
this._reconnectTimer = null
}
}
private _generateReconnectToken(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
}
private _cleanup(): void {
this._localPlayerId = 0
this._syncSystem?.clearSnapshots()
this._predictionSystem?.reset()
this._inputSystem?.reset()
}
// =========================================================================
// Game API | 游戏接口
// =========================================================================
/**
* @zh 注册预制体工厂
* @en Register prefab factory
@@ -196,4 +527,78 @@ export class NetworkPlugin implements IPlugin {
public sendActionInput(action: string): void {
this._inputSystem?.addActionInput(action)
}
/**
* @zh 设置本地玩家网络 ID用于预测
* @en Set local player network ID (for prediction)
*/
public setLocalPlayerNetId(netId: number): void {
if (this._predictionSystem) {
this._predictionSystem.setLocalPlayerNetId(netId)
}
}
/**
* @zh 启用/禁用预测
* @en Enable/disable prediction
*/
public setPredictionEnabled(enabled: boolean): void {
if (this._predictionSystem) {
this._predictionSystem.enabled = enabled
}
}
// =========================================================================
// AOI API | AOI 接口
// =========================================================================
/**
* @zh 添加 AOI 观察者(玩家)
* @en Add AOI observer (player)
*/
public addAOIObserver(netId: number, x: number, y: number, viewRange?: number): void {
this._aoiSystem?.addObserver(netId, x, y, viewRange)
}
/**
* @zh 移除 AOI 观察者
* @en Remove AOI observer
*/
public removeAOIObserver(netId: number): void {
this._aoiSystem?.removeObserver(netId)
}
/**
* @zh 更新 AOI 观察者位置
* @en Update AOI observer position
*/
public updateAOIObserverPosition(netId: number, x: number, y: number): void {
this._aoiSystem?.updateObserverPosition(netId, x, y)
}
/**
* @zh 获取观察者可见的实体
* @en Get entities visible to observer
*/
public getVisibleEntities(observerNetId: number): number[] {
return this._aoiSystem?.getVisibleEntities(observerNetId) ?? []
}
/**
* @zh 检查是否可见
* @en Check if visible
*/
public canSee(observerNetId: number, targetNetId: number): boolean {
return this._aoiSystem?.canSee(observerNetId, targetNetId) ?? true
}
/**
* @zh 启用/禁用 AOI
* @en Enable/disable AOI
*/
public setAOIEnabled(enabled: boolean): void {
if (this._aoiSystem) {
this._aoiSystem.enabled = enabled
}
}
}

View File

@@ -35,8 +35,11 @@ export {
type SyncData,
type SpawnData,
type DespawnData,
type FullStateData,
type JoinRequest,
type JoinResponse,
type ReconnectRequest,
type ReconnectResponse,
} from './protocol'
// ============================================================================
@@ -48,6 +51,8 @@ export {
NetworkSyncSystemToken,
NetworkSpawnSystemToken,
NetworkInputSystemToken,
NetworkPredictionSystemToken,
NetworkAOISystemToken,
} from './tokens'
// ============================================================================
@@ -81,10 +86,30 @@ export { NetworkTransform } from './components/NetworkTransform'
// ============================================================================
export { NetworkSyncSystem } from './systems/NetworkSyncSystem'
export type { SyncMessage } from './systems/NetworkSyncSystem'
export type { SyncMessage, NetworkSyncConfig } from './systems/NetworkSyncSystem'
export { NetworkSpawnSystem } from './systems/NetworkSpawnSystem'
export type { PrefabFactory, SpawnMessage, DespawnMessage } from './systems/NetworkSpawnSystem'
export { NetworkInputSystem } from './systems/NetworkInputSystem'
export { NetworkInputSystem, createNetworkInputSystem } from './systems/NetworkInputSystem'
export type { NetworkInputConfig } from './systems/NetworkInputSystem'
export {
NetworkPredictionSystem,
createNetworkPredictionSystem,
} from './systems/NetworkPredictionSystem'
export type {
NetworkPredictionConfig,
MovementInput,
PredictedTransform,
} from './systems/NetworkPredictionSystem'
export {
NetworkAOISystem,
createNetworkAOISystem,
} from './systems/NetworkAOISystem'
export type {
NetworkAOIConfig,
NetworkAOIEvent,
NetworkAOIEventType,
NetworkAOIEventListener,
} from './systems/NetworkAOISystem'
// ============================================================================
// State Sync | 状态同步
@@ -105,6 +130,9 @@ export type {
IPredictedState,
IPredictor,
ClientPredictionConfig,
EntityDeltaState,
DeltaSyncData,
DeltaCompressionConfig,
} from './sync'
export {
@@ -119,6 +147,9 @@ export {
createHermiteTransformInterpolator,
ClientPrediction,
createClientPrediction,
DeltaFlags,
StateDeltaCompressor,
createStateDeltaCompressor,
} from './sync'
// ============================================================================

View File

@@ -17,12 +17,24 @@ import { rpc } from '@esengine/rpc'
* @en Player input
*/
export interface PlayerInput {
/**
* @zh 输入序列号(用于客户端预测)
* @en Input sequence number (for client prediction)
*/
seq: number
/**
* @zh 帧序号
* @en Frame number
*/
frame: number
/**
* @zh 客户端时间戳
* @en Client timestamp
*/
timestamp: number
/**
* @zh 移动方向
* @en Move direction
@@ -41,9 +53,41 @@ export interface PlayerInput {
* @en Entity sync state
*/
export interface EntitySyncState {
/**
* @zh 网络实体 ID
* @en Network entity ID
*/
netId: number
/**
* @zh 位置
* @en Position
*/
pos?: { x: number; y: number }
/**
* @zh 旋转角度
* @en Rotation angle
*/
rot?: number
/**
* @zh 速度(用于外推)
* @en Velocity (for extrapolation)
*/
vel?: { x: number; y: number }
/**
* @zh 角速度
* @en Angular velocity
*/
angVel?: number
/**
* @zh 自定义数据
* @en Custom data
*/
custom?: Record<string, unknown>
}
/**
@@ -57,6 +101,18 @@ export interface SyncData {
*/
frame: number
/**
* @zh 服务器时间戳(用于插值)
* @en Server timestamp (for interpolation)
*/
timestamp: number
/**
* @zh 已确认的输入序列号(用于客户端预测校正)
* @en Acknowledged input sequence (for client prediction reconciliation)
*/
ackSeq?: number
/**
* @zh 实体状态列表
* @en Entity state list
@@ -84,6 +140,30 @@ export interface DespawnData {
netId: number
}
/**
* @zh 完整状态快照(用于重连)
* @en Full state snapshot (for reconnection)
*/
export interface FullStateData {
/**
* @zh 服务器帧号
* @en Server frame number
*/
frame: number
/**
* @zh 服务器时间戳
* @en Server timestamp
*/
timestamp: number
/**
* @zh 所有实体状态
* @en All entity states
*/
entities: Array<SpawnData & { state?: EntitySyncState }>
}
// ============================================================================
// API Types | API 类型
// ============================================================================
@@ -106,6 +186,54 @@ export interface JoinResponse {
roomId: string
}
/**
* @zh 重连请求
* @en Reconnect request
*/
export interface ReconnectRequest {
/**
* @zh 之前的玩家 ID
* @en Previous player ID
*/
playerId: number
/**
* @zh 房间 ID
* @en Room ID
*/
roomId: string
/**
* @zh 重连令牌
* @en Reconnection token
*/
token: string
}
/**
* @zh 重连响应
* @en Reconnect response
*/
export interface ReconnectResponse {
/**
* @zh 是否成功
* @en Whether successful
*/
success: boolean
/**
* @zh 完整状态(成功时)
* @en Full state (when successful)
*/
state?: FullStateData
/**
* @zh 错误信息(失败时)
* @en Error message (when failed)
*/
error?: string
}
// ============================================================================
// Protocol Definition | 协议定义
// ============================================================================
@@ -145,6 +273,12 @@ export const gameProtocol = rpc.define({
* @en Leave room
*/
leave: rpc.api<void, void>(),
/**
* @zh 重连
* @en Reconnect
*/
reconnect: rpc.api<ReconnectRequest, ReconnectResponse>(),
},
msg: {
/**
@@ -170,6 +304,12 @@ export const gameProtocol = rpc.define({
* @en Entity despawn
*/
despawn: rpc.msg<DespawnData>(),
/**
* @zh 完整状态快照
* @en Full state snapshot
*/
fullState: rpc.msg<FullStateData>(),
},
})

View File

@@ -0,0 +1,440 @@
/**
* @zh 状态增量压缩
* @en State Delta Compression
*
* @zh 通过只发送变化的字段来减少网络带宽
* @en Reduces network bandwidth by only sending changed fields
*/
import type { EntitySyncState, SyncData } from '../protocol'
// =============================================================================
// Types | 类型定义
// =============================================================================
/**
* @zh 增量类型标志
* @en Delta type flags
*/
export const DeltaFlags = {
NONE: 0,
POSITION: 1 << 0,
ROTATION: 1 << 1,
VELOCITY: 1 << 2,
ANGULAR_VELOCITY: 1 << 3,
CUSTOM: 1 << 4,
} as const
/**
* @zh 增量状态(只包含变化的字段)
* @en Delta state (only contains changed fields)
*/
export interface EntityDeltaState {
/**
* @zh 网络标识
* @en Network identity
*/
netId: number
/**
* @zh 变化标志
* @en Change flags
*/
flags: number
/**
* @zh 位置(如果变化)
* @en Position (if changed)
*/
pos?: { x: number; y: number }
/**
* @zh 旋转(如果变化)
* @en Rotation (if changed)
*/
rot?: number
/**
* @zh 速度(如果变化)
* @en Velocity (if changed)
*/
vel?: { x: number; y: number }
/**
* @zh 角速度(如果变化)
* @en Angular velocity (if changed)
*/
angVel?: number
/**
* @zh 自定义数据(如果变化)
* @en Custom data (if changed)
*/
custom?: Record<string, unknown>
}
/**
* @zh 增量同步数据
* @en Delta sync data
*/
export interface DeltaSyncData {
/**
* @zh 帧号
* @en Frame number
*/
frame: number
/**
* @zh 时间戳
* @en Timestamp
*/
timestamp: number
/**
* @zh 已确认的输入序列号
* @en Acknowledged input sequence
*/
ackSeq?: number
/**
* @zh 增量实体状态
* @en Delta entity states
*/
entities: EntityDeltaState[]
/**
* @zh 是否为完整快照
* @en Whether this is a full snapshot
*/
isFullSnapshot?: boolean
}
/**
* @zh 增量压缩配置
* @en Delta compression configuration
*/
export interface DeltaCompressionConfig {
/**
* @zh 位置变化阈值
* @en Position change threshold
*/
positionThreshold: number
/**
* @zh 旋转变化阈值(弧度)
* @en Rotation change threshold (radians)
*/
rotationThreshold: number
/**
* @zh 速度变化阈值
* @en Velocity change threshold
*/
velocityThreshold: number
/**
* @zh 强制完整快照间隔(帧数)
* @en Forced full snapshot interval (frames)
*/
fullSnapshotInterval: number
}
const DEFAULT_CONFIG: DeltaCompressionConfig = {
positionThreshold: 0.01,
rotationThreshold: 0.001,
velocityThreshold: 0.1,
fullSnapshotInterval: 60,
}
// =============================================================================
// StateDeltaCompressor | 状态增量压缩器
// =============================================================================
/**
* @zh 状态增量压缩器
* @en State delta compressor
*
* @zh 追踪实体状态变化,生成增量更新
* @en Tracks entity state changes and generates delta updates
*/
export class StateDeltaCompressor {
private readonly _config: DeltaCompressionConfig
private readonly _lastStates: Map<number, EntitySyncState> = new Map()
private _frameCounter: number = 0
constructor(config?: Partial<DeltaCompressionConfig>) {
this._config = { ...DEFAULT_CONFIG, ...config }
}
/**
* @zh 获取配置
* @en Get configuration
*/
get config(): Readonly<DeltaCompressionConfig> {
return this._config
}
/**
* @zh 压缩同步数据为增量格式
* @en Compress sync data to delta format
*/
compress(data: SyncData): DeltaSyncData {
this._frameCounter++
const isFullSnapshot = this._frameCounter % this._config.fullSnapshotInterval === 0
const deltaEntities: EntityDeltaState[] = []
for (const entity of data.entities) {
const lastState = this._lastStates.get(entity.netId)
if (isFullSnapshot || !lastState) {
// Send full state
deltaEntities.push(this._createFullDelta(entity))
} else {
// Calculate delta
const delta = this._calculateDelta(lastState, entity)
if (delta) {
deltaEntities.push(delta)
}
}
// Update last state
this._lastStates.set(entity.netId, { ...entity })
}
return {
frame: data.frame,
timestamp: data.timestamp,
ackSeq: data.ackSeq,
entities: deltaEntities,
isFullSnapshot,
}
}
/**
* @zh 解压增量数据为完整同步数据
* @en Decompress delta data to full sync data
*/
decompress(data: DeltaSyncData): SyncData {
const entities: EntitySyncState[] = []
for (const delta of data.entities) {
const lastState = this._lastStates.get(delta.netId)
const fullState = this._applyDelta(lastState, delta)
entities.push(fullState)
// Update last state
this._lastStates.set(delta.netId, fullState)
}
return {
frame: data.frame,
timestamp: data.timestamp,
ackSeq: data.ackSeq,
entities,
}
}
/**
* @zh 移除实体状态
* @en Remove entity state
*/
removeEntity(netId: number): void {
this._lastStates.delete(netId)
}
/**
* @zh 清除所有状态
* @en Clear all states
*/
clear(): void {
this._lastStates.clear()
this._frameCounter = 0
}
/**
* @zh 强制下一次发送完整快照
* @en Force next send to be a full snapshot
*/
forceFullSnapshot(): void {
this._frameCounter = this._config.fullSnapshotInterval - 1
}
// =========================================================================
// 私有方法 | Private Methods
// =========================================================================
private _createFullDelta(entity: EntitySyncState): EntityDeltaState {
let flags = 0
if (entity.pos) flags |= DeltaFlags.POSITION
if (entity.rot !== undefined) flags |= DeltaFlags.ROTATION
if (entity.vel) flags |= DeltaFlags.VELOCITY
if (entity.angVel !== undefined) flags |= DeltaFlags.ANGULAR_VELOCITY
if (entity.custom) flags |= DeltaFlags.CUSTOM
return {
netId: entity.netId,
flags,
pos: entity.pos,
rot: entity.rot,
vel: entity.vel,
angVel: entity.angVel,
custom: entity.custom,
}
}
private _calculateDelta(
lastState: EntitySyncState,
currentState: EntitySyncState
): EntityDeltaState | null {
let flags = 0
const delta: EntityDeltaState = {
netId: currentState.netId,
flags: 0,
}
// Check position change
if (currentState.pos) {
const posChanged = !lastState.pos ||
Math.abs(currentState.pos.x - lastState.pos.x) > this._config.positionThreshold ||
Math.abs(currentState.pos.y - lastState.pos.y) > this._config.positionThreshold
if (posChanged) {
flags |= DeltaFlags.POSITION
delta.pos = currentState.pos
}
}
// Check rotation change
if (currentState.rot !== undefined) {
const rotChanged = lastState.rot === undefined ||
Math.abs(currentState.rot - lastState.rot) > this._config.rotationThreshold
if (rotChanged) {
flags |= DeltaFlags.ROTATION
delta.rot = currentState.rot
}
}
// Check velocity change
if (currentState.vel) {
const velChanged = !lastState.vel ||
Math.abs(currentState.vel.x - lastState.vel.x) > this._config.velocityThreshold ||
Math.abs(currentState.vel.y - lastState.vel.y) > this._config.velocityThreshold
if (velChanged) {
flags |= DeltaFlags.VELOCITY
delta.vel = currentState.vel
}
}
// Check angular velocity change
if (currentState.angVel !== undefined) {
const angVelChanged = lastState.angVel === undefined ||
Math.abs(currentState.angVel - lastState.angVel) > this._config.velocityThreshold
if (angVelChanged) {
flags |= DeltaFlags.ANGULAR_VELOCITY
delta.angVel = currentState.angVel
}
}
// Check custom data change (simple reference comparison)
if (currentState.custom) {
const customChanged = !this._customDataEqual(lastState.custom, currentState.custom)
if (customChanged) {
flags |= DeltaFlags.CUSTOM
delta.custom = currentState.custom
}
}
// Return null if no changes
if (flags === 0) {
return null
}
delta.flags = flags
return delta
}
private _applyDelta(
lastState: EntitySyncState | undefined,
delta: EntityDeltaState
): EntitySyncState {
const state: EntitySyncState = {
netId: delta.netId,
}
// Apply position
if (delta.flags & DeltaFlags.POSITION) {
state.pos = delta.pos
} else if (lastState?.pos) {
state.pos = lastState.pos
}
// Apply rotation
if (delta.flags & DeltaFlags.ROTATION) {
state.rot = delta.rot
} else if (lastState?.rot !== undefined) {
state.rot = lastState.rot
}
// Apply velocity
if (delta.flags & DeltaFlags.VELOCITY) {
state.vel = delta.vel
} else if (lastState?.vel) {
state.vel = lastState.vel
}
// Apply angular velocity
if (delta.flags & DeltaFlags.ANGULAR_VELOCITY) {
state.angVel = delta.angVel
} else if (lastState?.angVel !== undefined) {
state.angVel = lastState.angVel
}
// Apply custom data
if (delta.flags & DeltaFlags.CUSTOM) {
state.custom = delta.custom
} else if (lastState?.custom) {
state.custom = lastState.custom
}
return state
}
private _customDataEqual(
a: Record<string, unknown> | undefined,
b: Record<string, unknown> | undefined
): boolean {
if (a === b) return true
if (!a || !b) return false
const keysA = Object.keys(a)
const keysB = Object.keys(b)
if (keysA.length !== keysB.length) return false
for (const key of keysA) {
if (a[key] !== b[key]) return false
}
return true
}
}
// =============================================================================
// 工厂函数 | Factory Functions
// =============================================================================
/**
* @zh 创建状态增量压缩器
* @en Create state delta compressor
*/
export function createStateDeltaCompressor(
config?: Partial<DeltaCompressionConfig>
): StateDeltaCompressor {
return new StateDeltaCompressor(config)
}

View File

@@ -46,3 +46,19 @@ export type {
} from './ClientPrediction';
export { ClientPrediction, createClientPrediction } from './ClientPrediction';
// =============================================================================
// 状态增量压缩 | State Delta Compression
// =============================================================================
export type {
EntityDeltaState,
DeltaSyncData,
DeltaCompressionConfig
} from './StateDelta';
export {
DeltaFlags,
StateDeltaCompressor,
createStateDeltaCompressor
} from './StateDelta';

View File

@@ -0,0 +1,500 @@
/**
* @zh 网络 AOI 系统
* @en Network AOI System
*
* @zh 集成 AOI 兴趣区域管理,过滤网络同步数据
* @en Integrates AOI interest management to filter network sync data
*/
import { EntitySystem, Matcher, type Entity } from '@esengine/ecs-framework'
import { NetworkIdentity } from '../components/NetworkIdentity'
import { NetworkTransform } from '../components/NetworkTransform'
import type { EntitySyncState } from '../protocol'
// =============================================================================
// Types | 类型定义
// =============================================================================
/**
* @zh AOI 事件类型
* @en AOI event type
*/
export type NetworkAOIEventType = 'enter' | 'exit'
/**
* @zh AOI 事件
* @en AOI event
*/
export interface NetworkAOIEvent {
/**
* @zh 事件类型
* @en Event type
*/
type: NetworkAOIEventType
/**
* @zh 观察者网络 ID玩家
* @en Observer network ID (player)
*/
observerNetId: number
/**
* @zh 目标网络 ID进入/离开视野的实体)
* @en Target network ID (entity entering/exiting view)
*/
targetNetId: number
}
/**
* @zh AOI 事件监听器
* @en AOI event listener
*/
export type NetworkAOIEventListener = (event: NetworkAOIEvent) => void
/**
* @zh 网络 AOI 配置
* @en Network AOI configuration
*/
export interface NetworkAOIConfig {
/**
* @zh 网格单元格大小
* @en Grid cell size
*/
cellSize: number
/**
* @zh 默认视野范围
* @en Default view range
*/
defaultViewRange: number
/**
* @zh 是否启用 AOI 过滤
* @en Whether to enable AOI filtering
*/
enabled: boolean
}
const DEFAULT_CONFIG: NetworkAOIConfig = {
cellSize: 100,
defaultViewRange: 500,
enabled: true,
}
/**
* @zh 观察者数据
* @en Observer data
*/
interface ObserverData {
netId: number
position: { x: number; y: number }
viewRange: number
viewRangeSq: number
cellKey: string
visibleEntities: Set<number>
}
// =============================================================================
// NetworkAOISystem | 网络 AOI 系统
// =============================================================================
/**
* @zh 网络 AOI 系统
* @en Network AOI system
*
* @zh 管理网络实体的兴趣区域,过滤同步数据
* @en Manages network entities' areas of interest and filters sync data
*/
export class NetworkAOISystem extends EntitySystem {
private readonly _config: NetworkAOIConfig
private readonly _observers: Map<number, ObserverData> = new Map()
private readonly _cells: Map<string, Set<number>> = new Map()
private readonly _listeners: Set<NetworkAOIEventListener> = new Set()
private readonly _entityNetIdMap: Map<Entity, number> = new Map()
private readonly _netIdEntityMap: Map<number, Entity> = new Map()
constructor(config?: Partial<NetworkAOIConfig>) {
super(Matcher.all(NetworkIdentity, NetworkTransform))
this._config = { ...DEFAULT_CONFIG, ...config }
}
/**
* @zh 获取配置
* @en Get configuration
*/
get config(): Readonly<NetworkAOIConfig> {
return this._config
}
/**
* @zh 是否启用
* @en Is enabled
*/
get enabled(): boolean {
return this._config.enabled
}
set enabled(value: boolean) {
this._config.enabled = value
}
/**
* @zh 观察者数量
* @en Observer count
*/
get observerCount(): number {
return this._observers.size
}
// =========================================================================
// 观察者管理 | Observer Management
// =========================================================================
/**
* @zh 添加观察者(通常是玩家实体)
* @en Add observer (usually player entity)
*/
addObserver(netId: number, x: number, y: number, viewRange?: number): void {
if (this._observers.has(netId)) {
this.updateObserverPosition(netId, x, y)
return
}
const range = viewRange ?? this._config.defaultViewRange
const cellKey = this._getCellKey(x, y)
const data: ObserverData = {
netId,
position: { x, y },
viewRange: range,
viewRangeSq: range * range,
cellKey,
visibleEntities: new Set(),
}
this._observers.set(netId, data)
this._addToCell(cellKey, netId)
this._updateVisibility(data)
}
/**
* @zh 移除观察者
* @en Remove observer
*/
removeObserver(netId: number): boolean {
const data = this._observers.get(netId)
if (!data) return false
// Emit exit events for all visible entities
for (const visibleNetId of data.visibleEntities) {
this._emitEvent({
type: 'exit',
observerNetId: netId,
targetNetId: visibleNetId,
})
}
this._removeFromCell(data.cellKey, netId)
this._observers.delete(netId)
return true
}
/**
* @zh 更新观察者位置
* @en Update observer position
*/
updateObserverPosition(netId: number, x: number, y: number): void {
const data = this._observers.get(netId)
if (!data) return
const newCellKey = this._getCellKey(x, y)
if (newCellKey !== data.cellKey) {
this._removeFromCell(data.cellKey, netId)
data.cellKey = newCellKey
this._addToCell(newCellKey, netId)
}
data.position.x = x
data.position.y = y
this._updateVisibility(data)
}
/**
* @zh 更新观察者视野范围
* @en Update observer view range
*/
updateObserverViewRange(netId: number, viewRange: number): void {
const data = this._observers.get(netId)
if (!data) return
data.viewRange = viewRange
data.viewRangeSq = viewRange * viewRange
this._updateVisibility(data)
}
// =========================================================================
// 实体管理 | Entity Management
// =========================================================================
/**
* @zh 注册网络实体
* @en Register network entity
*/
registerEntity(entity: Entity, netId: number): void {
this._entityNetIdMap.set(entity, netId)
this._netIdEntityMap.set(netId, entity)
}
/**
* @zh 注销网络实体
* @en Unregister network entity
*/
unregisterEntity(entity: Entity): void {
const netId = this._entityNetIdMap.get(entity)
if (netId !== undefined) {
// Remove from all observers' visible sets
for (const [, data] of this._observers) {
if (data.visibleEntities.has(netId)) {
data.visibleEntities.delete(netId)
this._emitEvent({
type: 'exit',
observerNetId: data.netId,
targetNetId: netId,
})
}
}
this._netIdEntityMap.delete(netId)
}
this._entityNetIdMap.delete(entity)
}
// =========================================================================
// 查询接口 | Query Interface
// =========================================================================
/**
* @zh 获取观察者能看到的实体网络 ID 列表
* @en Get list of entity network IDs visible to observer
*/
getVisibleEntities(observerNetId: number): number[] {
const data = this._observers.get(observerNetId)
return data ? Array.from(data.visibleEntities) : []
}
/**
* @zh 获取能看到指定实体的观察者网络 ID 列表
* @en Get list of observer network IDs that can see the entity
*/
getObserversOf(entityNetId: number): number[] {
const observers: number[] = []
for (const [, data] of this._observers) {
if (data.visibleEntities.has(entityNetId)) {
observers.push(data.netId)
}
}
return observers
}
/**
* @zh 检查观察者是否能看到目标
* @en Check if observer can see target
*/
canSee(observerNetId: number, targetNetId: number): boolean {
const data = this._observers.get(observerNetId)
return data?.visibleEntities.has(targetNetId) ?? false
}
/**
* @zh 过滤同步数据,只保留观察者能看到的实体
* @en Filter sync data to only include entities visible to observer
*/
filterSyncData(observerNetId: number, entities: EntitySyncState[]): EntitySyncState[] {
if (!this._config.enabled) {
return entities
}
const data = this._observers.get(observerNetId)
if (!data) {
return entities
}
return entities.filter(entity => {
// Always include the observer's own entity
if (entity.netId === observerNetId) return true
// Include entities in view
return data.visibleEntities.has(entity.netId)
})
}
// =========================================================================
// 事件系统 | Event System
// =========================================================================
/**
* @zh 添加事件监听器
* @en Add event listener
*/
addListener(listener: NetworkAOIEventListener): void {
this._listeners.add(listener)
}
/**
* @zh 移除事件监听器
* @en Remove event listener
*/
removeListener(listener: NetworkAOIEventListener): void {
this._listeners.delete(listener)
}
// =========================================================================
// 系统生命周期 | System Lifecycle
// =========================================================================
protected override process(entities: readonly Entity[]): void {
if (!this._config.enabled) return
// Update entity positions for AOI calculations
for (const entity of entities) {
const identity = this.requireComponent(entity, NetworkIdentity)
const transform = this.requireComponent(entity, NetworkTransform)
// Register entity if not already registered
if (!this._entityNetIdMap.has(entity)) {
this.registerEntity(entity, identity.netId)
}
// If this entity is an observer (has authority), update its position
if (identity.bHasAuthority && this._observers.has(identity.netId)) {
this.updateObserverPosition(
identity.netId,
transform.currentX,
transform.currentY
)
}
}
// Update all observers' visibility based on entity positions
this._updateAllObserversVisibility(entities)
}
private _updateAllObserversVisibility(entities: readonly Entity[]): void {
for (const [, data] of this._observers) {
const newVisible = new Set<number>()
// Check all entities
for (const entity of entities) {
const identity = this.requireComponent(entity, NetworkIdentity)
const transform = this.requireComponent(entity, NetworkTransform)
// Skip self
if (identity.netId === data.netId) continue
// Check distance
const dx = transform.currentX - data.position.x
const dy = transform.currentY - data.position.y
const distSq = dx * dx + dy * dy
if (distSq <= data.viewRangeSq) {
newVisible.add(identity.netId)
}
}
// Find entities that entered view
for (const netId of newVisible) {
if (!data.visibleEntities.has(netId)) {
this._emitEvent({
type: 'enter',
observerNetId: data.netId,
targetNetId: netId,
})
}
}
// Find entities that exited view
for (const netId of data.visibleEntities) {
if (!newVisible.has(netId)) {
this._emitEvent({
type: 'exit',
observerNetId: data.netId,
targetNetId: netId,
})
}
}
data.visibleEntities = newVisible
}
}
/**
* @zh 清除所有数据
* @en Clear all data
*/
clear(): void {
this._observers.clear()
this._cells.clear()
this._entityNetIdMap.clear()
this._netIdEntityMap.clear()
}
protected override onDestroy(): void {
this.clear()
this._listeners.clear()
}
// =========================================================================
// 私有方法 | Private Methods
// =========================================================================
private _getCellKey(x: number, y: number): string {
const cellX = Math.floor(x / this._config.cellSize)
const cellY = Math.floor(y / this._config.cellSize)
return `${cellX},${cellY}`
}
private _addToCell(cellKey: string, netId: number): void {
let cell = this._cells.get(cellKey)
if (!cell) {
cell = new Set()
this._cells.set(cellKey, cell)
}
cell.add(netId)
}
private _removeFromCell(cellKey: string, netId: number): void {
const cell = this._cells.get(cellKey)
if (cell) {
cell.delete(netId)
if (cell.size === 0) {
this._cells.delete(cellKey)
}
}
}
private _updateVisibility(data: ObserverData): void {
// This is called when an observer moves
// The full visibility update happens in process() with all entities
}
private _emitEvent(event: NetworkAOIEvent): void {
for (const listener of this._listeners) {
try {
listener(event)
} catch (e) {
console.error('[NetworkAOISystem] Listener error:', e)
}
}
}
}
// =============================================================================
// 工厂函数 | Factory Functions
// =============================================================================
/**
* @zh 创建网络 AOI 系统
* @en Create network AOI system
*/
export function createNetworkAOISystem(
config?: Partial<NetworkAOIConfig>
): NetworkAOISystem {
return new NetworkAOISystem(config)
}

View File

@@ -1,11 +1,63 @@
/**
* @zh 网络输入系统
* @en Network Input System
*
* @zh 收集本地玩家输入并发送到服务器,支持与预测系统集成
* @en Collects local player input and sends to server, supports integration with prediction system
*/
import { EntitySystem, Matcher } from '@esengine/ecs-framework'
import type { PlayerInput } from '../protocol'
import type { NetworkService } from '../services/NetworkService'
import type { NetworkPredictionSystem } from './NetworkPredictionSystem'
// =============================================================================
// Types | 类型定义
// =============================================================================
/**
* @zh 输入配置
* @en Input configuration
*/
export interface NetworkInputConfig {
/**
* @zh 发送输入的最小间隔(毫秒)
* @en Minimum interval between input sends (milliseconds)
*/
sendInterval: number
/**
* @zh 是否合并相同输入
* @en Whether to merge identical inputs
*/
mergeIdenticalInputs: boolean
/**
* @zh 最大输入队列长度
* @en Maximum input queue length
*/
maxQueueLength: number
}
const DEFAULT_CONFIG: NetworkInputConfig = {
sendInterval: 16, // ~60fps
mergeIdenticalInputs: true,
maxQueueLength: 10,
}
/**
* @zh 待发送输入
* @en Pending input
*/
interface PendingInput {
moveDir?: { x: number; y: number }
actions?: string[]
timestamp: number
}
// =============================================================================
// NetworkInputSystem | 网络输入系统
// =============================================================================
/**
* @zh 网络输入系统
@@ -15,13 +67,52 @@ import type { NetworkService } from '../services/NetworkService'
* @en Collects local player input and sends to server
*/
export class NetworkInputSystem extends EntitySystem {
private _networkService: NetworkService
private _frame: number = 0
private _inputQueue: PlayerInput[] = []
private readonly _networkService: NetworkService
private readonly _config: NetworkInputConfig
private _predictionSystem: NetworkPredictionSystem | null = null
constructor(networkService: NetworkService) {
private _frame: number = 0
private _inputSequence: number = 0
private _inputQueue: PendingInput[] = []
private _lastSendTime: number = 0
private _lastMoveDir: { x: number; y: number } = { x: 0, y: 0 }
constructor(networkService: NetworkService, config?: Partial<NetworkInputConfig>) {
super(Matcher.nothing())
this._networkService = networkService
this._config = { ...DEFAULT_CONFIG, ...config }
}
/**
* @zh 获取配置
* @en Get configuration
*/
get config(): Readonly<NetworkInputConfig> {
return this._config
}
/**
* @zh 获取当前帧号
* @en Get current frame number
*/
get frame(): number {
return this._frame
}
/**
* @zh 获取当前输入序列号
* @en Get current input sequence
*/
get inputSequence(): number {
return this._inputSequence
}
/**
* @zh 设置预测系统引用
* @en Set prediction system reference
*/
setPredictionSystem(system: NetworkPredictionSystem): void {
this._predictionSystem = system
}
/**
@@ -32,11 +123,64 @@ export class NetworkInputSystem extends EntitySystem {
if (!this._networkService.isConnected) return
this._frame++
const now = Date.now()
while (this._inputQueue.length > 0) {
const input = this._inputQueue.shift()!
input.frame = this._frame
this._networkService.sendInput(input)
// Rate limiting
if (now - this._lastSendTime < this._config.sendInterval) return
// If using prediction system, get input from there
if (this._predictionSystem) {
const predictedInput = this._predictionSystem.getInputToSend()
if (predictedInput) {
this._networkService.sendInput(predictedInput)
this._lastSendTime = now
}
return
}
// Otherwise process queue
if (this._inputQueue.length === 0) return
// Merge inputs if configured
let mergedInput: PendingInput
if (this._config.mergeIdenticalInputs && this._inputQueue.length > 1) {
mergedInput = this._mergeInputs(this._inputQueue)
this._inputQueue.length = 0
} else {
mergedInput = this._inputQueue.shift()!
}
// Build and send input
this._inputSequence++
const input: PlayerInput = {
seq: this._inputSequence,
frame: this._frame,
timestamp: mergedInput.timestamp,
moveDir: mergedInput.moveDir,
actions: mergedInput.actions,
}
this._networkService.sendInput(input)
this._lastSendTime = now
}
private _mergeInputs(inputs: PendingInput[]): PendingInput {
const allActions: string[] = []
let lastMoveDir: { x: number; y: number } | undefined
for (const input of inputs) {
if (input.moveDir) {
lastMoveDir = input.moveDir
}
if (input.actions) {
allActions.push(...input.actions)
}
}
return {
moveDir: lastMoveDir,
actions: allActions.length > 0 ? allActions : undefined,
timestamp: inputs[inputs.length - 1].timestamp,
}
}
@@ -45,10 +189,24 @@ export class NetworkInputSystem extends EntitySystem {
* @en Add move input
*/
public addMoveInput(x: number, y: number): void {
this._inputQueue.push({
frame: 0,
moveDir: { x, y },
})
// Skip if same as last input
if (
this._config.mergeIdenticalInputs &&
this._lastMoveDir.x === x &&
this._lastMoveDir.y === y &&
this._inputQueue.length > 0
) {
return
}
this._lastMoveDir = { x, y }
// Also set input on prediction system
if (this._predictionSystem) {
this._predictionSystem.setInput(x, y)
}
this._addToQueue({ moveDir: { x, y }, timestamp: Date.now() })
}
/**
@@ -56,19 +214,70 @@ export class NetworkInputSystem extends EntitySystem {
* @en Add action input
*/
public addActionInput(action: string): void {
// Try to add to last input in queue
const lastInput = this._inputQueue[this._inputQueue.length - 1]
if (lastInput) {
lastInput.actions = lastInput.actions || []
lastInput.actions.push(action)
} else {
this._inputQueue.push({
frame: 0,
actions: [action],
})
this._addToQueue({ actions: [action], timestamp: Date.now() })
}
// Also set on prediction system
if (this._predictionSystem) {
this._predictionSystem.setInput(
this._lastMoveDir.x,
this._lastMoveDir.y,
[action]
)
}
}
private _addToQueue(input: PendingInput): void {
this._inputQueue.push(input)
// Limit queue size
while (this._inputQueue.length > this._config.maxQueueLength) {
this._inputQueue.shift()
}
}
/**
* @zh 清空输入队列
* @en Clear input queue
*/
public clearQueue(): void {
this._inputQueue.length = 0
this._lastMoveDir = { x: 0, y: 0 }
}
/**
* @zh 重置状态
* @en Reset state
*/
public reset(): void {
this._frame = 0
this._inputSequence = 0
this.clearQueue()
}
protected override onDestroy(): void {
this._inputQueue.length = 0
this._predictionSystem = null
}
}
// =============================================================================
// 工厂函数 | Factory Functions
// =============================================================================
/**
* @zh 创建网络输入系统
* @en Create network input system
*/
export function createNetworkInputSystem(
networkService: NetworkService,
config?: Partial<NetworkInputConfig>
): NetworkInputSystem {
return new NetworkInputSystem(networkService, config)
}

View File

@@ -0,0 +1,309 @@
/**
* @zh 网络预测系统
* @en Network Prediction System
*
* @zh 处理本地玩家的客户端预测和服务器校正
* @en Handles client-side prediction and server reconciliation for local player
*/
import { EntitySystem, Matcher, Time, type Entity } from '@esengine/ecs-framework'
import { NetworkIdentity } from '../components/NetworkIdentity'
import { NetworkTransform } from '../components/NetworkTransform'
import type { SyncData, PlayerInput } from '../protocol'
import {
ClientPrediction,
createClientPrediction,
type IPredictor,
type ClientPredictionConfig,
type ITransformState,
} from '../sync'
// =============================================================================
// Types | 类型定义
// =============================================================================
/**
* @zh 移动输入
* @en Movement input
*/
export interface MovementInput {
x: number
y: number
actions?: string[]
}
/**
* @zh 预测状态(位置 + 旋转)
* @en Predicted state (position + rotation)
*/
export interface PredictedTransform extends ITransformState {
velocityX: number
velocityY: number
}
/**
* @zh 预测系统配置
* @en Prediction system configuration
*/
export interface NetworkPredictionConfig extends Partial<ClientPredictionConfig> {
/**
* @zh 移动速度(单位/秒)
* @en Movement speed (units/second)
*/
moveSpeed: number
/**
* @zh 是否启用预测
* @en Whether prediction is enabled
*/
enabled: boolean
}
const DEFAULT_CONFIG: NetworkPredictionConfig = {
moveSpeed: 200,
enabled: true,
maxUnacknowledgedInputs: 60,
reconciliationThreshold: 0.5,
reconciliationSpeed: 10,
}
// =============================================================================
// 默认预测器 | Default Predictor
// =============================================================================
/**
* @zh 简单移动预测器
* @en Simple movement predictor
*/
class SimpleMovementPredictor implements IPredictor<PredictedTransform, MovementInput> {
constructor(private readonly _moveSpeed: number) {}
predict(state: PredictedTransform, input: MovementInput, deltaTime: number): PredictedTransform {
const velocityX = input.x * this._moveSpeed
const velocityY = input.y * this._moveSpeed
return {
x: state.x + velocityX * deltaTime,
y: state.y + velocityY * deltaTime,
rotation: state.rotation,
velocityX,
velocityY,
}
}
}
// =============================================================================
// NetworkPredictionSystem | 网络预测系统
// =============================================================================
/**
* @zh 网络预测系统
* @en Network prediction system
*
* @zh 处理本地玩家的输入预测和服务器状态校正
* @en Handles local player input prediction and server state reconciliation
*/
export class NetworkPredictionSystem extends EntitySystem {
private readonly _config: NetworkPredictionConfig
private readonly _predictor: IPredictor<PredictedTransform, MovementInput>
private _prediction: ClientPrediction<PredictedTransform, MovementInput> | null = null
private _localPlayerNetId: number = -1
private _currentInput: MovementInput = { x: 0, y: 0 }
private _inputSequence: number = 0
constructor(config?: Partial<NetworkPredictionConfig>) {
super(Matcher.all(NetworkIdentity, NetworkTransform))
this._config = { ...DEFAULT_CONFIG, ...config }
this._predictor = new SimpleMovementPredictor(this._config.moveSpeed)
}
/**
* @zh 获取配置
* @en Get configuration
*/
get config(): Readonly<NetworkPredictionConfig> {
return this._config
}
/**
* @zh 获取当前输入序列号
* @en Get current input sequence number
*/
get inputSequence(): number {
return this._inputSequence
}
/**
* @zh 获取待确认输入数量
* @en Get pending input count
*/
get pendingInputCount(): number {
return this._prediction?.pendingInputCount ?? 0
}
/**
* @zh 是否启用预测
* @en Whether prediction is enabled
*/
get enabled(): boolean {
return this._config.enabled
}
set enabled(value: boolean) {
this._config.enabled = value
}
/**
* @zh 设置本地玩家网络 ID
* @en Set local player network ID
*/
setLocalPlayerNetId(netId: number): void {
this._localPlayerNetId = netId
this._prediction = createClientPrediction<PredictedTransform, MovementInput>(
this._predictor,
{
maxUnacknowledgedInputs: this._config.maxUnacknowledgedInputs,
reconciliationThreshold: this._config.reconciliationThreshold,
reconciliationSpeed: this._config.reconciliationSpeed,
}
)
}
/**
* @zh 设置移动输入
* @en Set movement input
*/
setInput(x: number, y: number, actions?: string[]): void {
this._currentInput = { x, y, actions }
}
/**
* @zh 获取下一个要发送的输入(带序列号)
* @en Get next input to send (with sequence number)
*/
getInputToSend(): PlayerInput | null {
if (!this._prediction) return null
const input = this._prediction.getInputToSend()
if (!input) return null
return {
seq: input.sequence,
frame: 0,
timestamp: input.timestamp,
moveDir: { x: input.input.x, y: input.input.y },
actions: input.input.actions,
}
}
/**
* @zh 处理服务器同步数据进行校正
* @en Process server sync data for reconciliation
*/
reconcileWithServer(data: SyncData): void {
if (!this._prediction || this._localPlayerNetId < 0) return
// Find local player state in sync data
const localState = data.entities.find(e => e.netId === this._localPlayerNetId)
if (!localState || !localState.pos) return
const serverState: PredictedTransform = {
x: localState.pos.x,
y: localState.pos.y,
rotation: localState.rot ?? 0,
velocityX: localState.vel?.x ?? 0,
velocityY: localState.vel?.y ?? 0,
}
// Reconcile prediction with server state
if (data.ackSeq !== undefined) {
this._prediction.reconcile(
serverState,
data.ackSeq,
(state) => ({ x: state.x, y: state.y }),
Time.deltaTime
)
}
}
protected override process(entities: readonly Entity[]): void {
if (!this._config.enabled || !this._prediction) return
const deltaTime = Time.deltaTime
for (const entity of entities) {
const identity = this.requireComponent(entity, NetworkIdentity)
// Only process local player with authority
if (!identity.bHasAuthority || identity.netId !== this._localPlayerNetId) continue
const transform = this.requireComponent(entity, NetworkTransform)
// Get current state
const currentState: PredictedTransform = {
x: transform.currentX,
y: transform.currentY,
rotation: transform.currentRotation,
velocityX: 0,
velocityY: 0,
}
// Record input and get predicted state
if (this._currentInput.x !== 0 || this._currentInput.y !== 0) {
const predicted = this._prediction.recordInput(
this._currentInput,
currentState,
deltaTime
)
// Apply predicted position
transform.currentX = predicted.x
transform.currentY = predicted.y
transform.currentRotation = predicted.rotation
// Update target to match (for rendering)
transform.targetX = predicted.x
transform.targetY = predicted.y
transform.targetRotation = predicted.rotation
this._inputSequence = this._prediction.currentSequence
}
// Apply correction offset smoothly
const offset = this._prediction.correctionOffset
if (Math.abs(offset.x) > 0.01 || Math.abs(offset.y) > 0.01) {
transform.currentX += offset.x * deltaTime * 5
transform.currentY += offset.y * deltaTime * 5
}
}
}
/**
* @zh 重置预测状态
* @en Reset prediction state
*/
reset(): void {
this._prediction?.clear()
this._inputSequence = 0
this._currentInput = { x: 0, y: 0 }
}
protected override onDestroy(): void {
this._prediction?.clear()
this._prediction = null
}
}
// =============================================================================
// 工厂函数 | Factory Functions
// =============================================================================
/**
* @zh 创建网络预测系统
* @en Create network prediction system
*/
export function createNetworkPredictionSystem(
config?: Partial<NetworkPredictionConfig>
): NetworkPredictionSystem {
return new NetworkPredictionSystem(config)
}

View File

@@ -1,10 +1,32 @@
/**
* @zh 网络同步系统
* @en Network Sync System
*
* @zh 处理网络实体的状态同步、快照缓冲和插值
* @en Handles state synchronization, snapshot buffering, and interpolation for networked entities
*/
import { EntitySystem, Matcher, Time, type Entity } from '@esengine/ecs-framework'
import { NetworkIdentity } from '../components/NetworkIdentity'
import { NetworkTransform } from '../components/NetworkTransform'
import type { SyncData, EntitySyncState } from '../protocol'
import {
SnapshotBuffer,
createSnapshotBuffer,
TransformInterpolator,
createTransformInterpolator,
type ITransformState,
type ITransformStateWithVelocity,
type IStateSnapshot,
} from '../sync'
// =============================================================================
// Types | 类型定义
// =============================================================================
/**
* @zh 同步消息接口
* @en Sync message interface
* @zh 同步消息接口(兼容旧版)
* @en Sync message interface (for backwards compatibility)
*/
export interface SyncMessage {
entities: Array<{
@@ -14,25 +36,134 @@ export interface SyncMessage {
}>
}
/**
* @zh 实体快照数据
* @en Entity snapshot data
*/
interface EntitySnapshotData {
buffer: SnapshotBuffer<ITransformStateWithVelocity>
lastServerTime: number
}
/**
* @zh 同步系统配置
* @en Sync system configuration
*/
export interface NetworkSyncConfig {
/**
* @zh 快照缓冲区大小
* @en Snapshot buffer size
*/
bufferSize: number
/**
* @zh 插值延迟(毫秒)
* @en Interpolation delay in milliseconds
*/
interpolationDelay: number
/**
* @zh 是否启用外推
* @en Whether to enable extrapolation
*/
enableExtrapolation: boolean
/**
* @zh 最大外推时间(毫秒)
* @en Maximum extrapolation time in milliseconds
*/
maxExtrapolationTime: number
/**
* @zh 使用赫尔米特插值(更平滑)
* @en Use Hermite interpolation (smoother)
*/
useHermiteInterpolation: boolean
}
const DEFAULT_CONFIG: NetworkSyncConfig = {
bufferSize: 30,
interpolationDelay: 100,
enableExtrapolation: true,
maxExtrapolationTime: 200,
useHermiteInterpolation: false,
}
// =============================================================================
// NetworkSyncSystem | 网络同步系统
// =============================================================================
/**
* @zh 网络同步系统
* @en Network sync system
*
* @zh 处理网络实体的状态同步和插值
* @en Handles state synchronization and interpolation for networked entities
* @zh 处理网络实体的状态同步和插值,支持快照缓冲、平滑插值和外推
* @en Handles state synchronization and interpolation for networked entities,
* supports snapshot buffering, smooth interpolation, and extrapolation
*/
export class NetworkSyncSystem extends EntitySystem {
private _netIdToEntity: Map<number, number> = new Map()
private readonly _netIdToEntity: Map<number, number> = new Map()
private readonly _entitySnapshots: Map<number, EntitySnapshotData> = new Map()
private readonly _interpolator: TransformInterpolator
private readonly _config: NetworkSyncConfig
constructor() {
private _serverTimeOffset: number = 0
private _lastSyncTime: number = 0
private _renderTime: number = 0
constructor(config?: Partial<NetworkSyncConfig>) {
super(Matcher.all(NetworkIdentity, NetworkTransform))
this._config = { ...DEFAULT_CONFIG, ...config }
this._interpolator = createTransformInterpolator()
}
/**
* @zh 处理同步消息
* @en Handle sync message
* @zh 获取配置
* @en Get configuration
*/
get config(): Readonly<NetworkSyncConfig> {
return this._config
}
/**
* @zh 获取服务器时间偏移
* @en Get server time offset
*/
get serverTimeOffset(): number {
return this._serverTimeOffset
}
/**
* @zh 获取当前渲染时间
* @en Get current render time
*/
get renderTime(): number {
return this._renderTime
}
/**
* @zh 处理同步消息(新版,带时间戳)
* @en Handle sync message (new version with timestamp)
*/
handleSyncData(data: SyncData): void {
const serverTime = data.timestamp
// Update server time offset
const clientTime = Date.now()
this._serverTimeOffset = serverTime - clientTime
this._lastSyncTime = clientTime
for (const state of data.entities) {
this._processEntityState(state, serverTime)
}
}
/**
* @zh 处理同步消息(兼容旧版)
* @en Handle sync message (backwards compatible)
*/
handleSync(msg: SyncMessage): void {
const now = Date.now()
for (const state of msg.entities) {
const entityId = this._netIdToEntity.get(state.netId)
if (entityId === undefined) continue
@@ -44,22 +175,133 @@ export class NetworkSyncSystem extends EntitySystem {
if (transform && state.pos) {
transform.setTarget(state.pos.x, state.pos.y, state.rot ?? 0)
}
// Also add to snapshot buffer for interpolation
this._processEntityState({
netId: state.netId,
pos: state.pos,
rot: state.rot,
}, now)
}
}
private _processEntityState(state: EntitySyncState, serverTime: number): void {
const entityId = this._netIdToEntity.get(state.netId)
if (entityId === undefined) return
// Get or create snapshot buffer
let snapshotData = this._entitySnapshots.get(state.netId)
if (!snapshotData) {
snapshotData = {
buffer: createSnapshotBuffer<ITransformStateWithVelocity>(
this._config.bufferSize,
this._config.interpolationDelay
),
lastServerTime: 0,
}
this._entitySnapshots.set(state.netId, snapshotData)
}
// Create snapshot
const transformState: ITransformStateWithVelocity = {
x: state.pos?.x ?? 0,
y: state.pos?.y ?? 0,
rotation: state.rot ?? 0,
velocityX: state.vel?.x ?? 0,
velocityY: state.vel?.y ?? 0,
angularVelocity: state.angVel ?? 0,
}
const snapshot: IStateSnapshot<ITransformStateWithVelocity> = {
timestamp: serverTime,
state: transformState,
}
snapshotData.buffer.push(snapshot)
snapshotData.lastServerTime = serverTime
}
protected override process(entities: readonly Entity[]): void {
const deltaTime = Time.deltaTime
const clientTime = Date.now()
// Calculate render time (current time adjusted for server offset)
this._renderTime = clientTime + this._serverTimeOffset
for (const entity of entities) {
const transform = this.requireComponent(entity, NetworkTransform)
const identity = this.requireComponent(entity, NetworkIdentity)
if (!identity.bHasAuthority && transform.bInterpolate) {
this._interpolate(transform, deltaTime)
// Skip entities with authority (local player handles their own movement)
if (identity.bHasAuthority) continue
if (transform.bInterpolate) {
this._interpolateEntity(identity.netId, transform, deltaTime)
}
}
}
private _interpolateEntity(
netId: number,
transform: NetworkTransform,
deltaTime: number
): void {
const snapshotData = this._entitySnapshots.get(netId)
if (snapshotData && snapshotData.buffer.size >= 2) {
// Use snapshot buffer for interpolation
const result = snapshotData.buffer.getInterpolationSnapshots(this._renderTime)
if (result) {
const [prev, next, t] = result
const interpolated = this._interpolator.interpolate(prev.state, next.state, t)
transform.currentX = interpolated.x
transform.currentY = interpolated.y
transform.currentRotation = interpolated.rotation
// Update target for compatibility
transform.targetX = next.state.x
transform.targetY = next.state.y
transform.targetRotation = next.state.rotation
return
}
// Extrapolation if enabled and we have velocity data
if (this._config.enableExtrapolation) {
const latest = snapshotData.buffer.getLatest()
if (latest) {
const timeSinceLastSnapshot = this._renderTime - latest.timestamp
if (timeSinceLastSnapshot > 0 && timeSinceLastSnapshot < this._config.maxExtrapolationTime) {
const extrapolated = this._interpolator.extrapolate(
latest.state,
timeSinceLastSnapshot / 1000
)
transform.currentX = extrapolated.x
transform.currentY = extrapolated.y
transform.currentRotation = extrapolated.rotation
return
}
}
}
}
// Fallback: simple lerp towards target
this._simpleLerp(transform, deltaTime)
}
private _simpleLerp(transform: NetworkTransform, deltaTime: number): void {
const t = Math.min(1, transform.lerpSpeed * deltaTime)
transform.currentX += (transform.targetX - transform.currentX) * t
transform.currentY += (transform.targetY - transform.currentY) * t
let angleDiff = transform.targetRotation - transform.currentRotation
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2
transform.currentRotation += angleDiff * t
}
/**
* @zh 注册网络实体
* @en Register network entity
@@ -74,6 +316,7 @@ export class NetworkSyncSystem extends EntitySystem {
*/
unregisterEntity(netId: number): void {
this._netIdToEntity.delete(netId)
this._entitySnapshots.delete(netId)
}
/**
@@ -84,19 +327,26 @@ export class NetworkSyncSystem extends EntitySystem {
return this._netIdToEntity.get(netId)
}
private _interpolate(transform: NetworkTransform, deltaTime: number): void {
const t = Math.min(1, transform.lerpSpeed * deltaTime)
/**
* @zh 获取实体的快照缓冲区
* @en Get entity's snapshot buffer
*/
getSnapshotBuffer(netId: number): SnapshotBuffer<ITransformStateWithVelocity> | undefined {
return this._entitySnapshots.get(netId)?.buffer
}
transform.currentX += (transform.targetX - transform.currentX) * t
transform.currentY += (transform.targetY - transform.currentY) * t
let angleDiff = transform.targetRotation - transform.currentRotation
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2
transform.currentRotation += angleDiff * t
/**
* @zh 清空所有快照缓冲
* @en Clear all snapshot buffers
*/
clearSnapshots(): void {
for (const data of this._entitySnapshots.values()) {
data.buffer.clear()
}
}
protected override onDestroy(): void {
this._netIdToEntity.clear()
this._entitySnapshots.clear()
}
}

View File

@@ -8,6 +8,8 @@ import type { NetworkService } from './services/NetworkService';
import type { NetworkSyncSystem } from './systems/NetworkSyncSystem';
import type { NetworkSpawnSystem } from './systems/NetworkSpawnSystem';
import type { NetworkInputSystem } from './systems/NetworkInputSystem';
import type { NetworkPredictionSystem } from './systems/NetworkPredictionSystem';
import type { NetworkAOISystem } from './systems/NetworkAOISystem';
// ============================================================================
// Network 模块导出的令牌 | Tokens exported by Network module
@@ -36,3 +38,15 @@ export const NetworkSpawnSystemToken = createServiceToken<NetworkSpawnSystem>('n
* Network input system token
*/
export const NetworkInputSystemToken = createServiceToken<NetworkInputSystem>('networkInputSystem');
/**
* 网络预测系统令牌
* Network prediction system token
*/
export const NetworkPredictionSystemToken = createServiceToken<NetworkPredictionSystem>('networkPredictionSystem');
/**
* 网络 AOI 系统令牌
* Network AOI system token
*/
export const NetworkAOISystemToken = createServiceToken<NetworkAOISystem>('networkAOISystem');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
# @esengine/server
## 1.1.4
### Patch Changes
- Updated dependencies [[`a000cc0`](https://github.com/esengine/esengine/commit/a000cc07d7cebe8ccbfa983fde610296bfba2f1b)]:
- @esengine/rpc@1.1.1
## 1.1.3
### Patch Changes
- [#372](https://github.com/esengine/esengine/pull/372) [`9c41181`](https://github.com/esengine/esengine/commit/9c4118187539e39ead48ef2fa7af3ff45285fde5) Thanks [@esengine](https://github.com/esengine)! - fix: expose `id` property on ServerConnection type
TypeScript was not properly resolving the inherited `id` property from the base `Connection` interface in some module resolution scenarios. This fix explicitly declares the `id` property on `ServerConnection` to ensure it's always visible to consumers.
## 1.1.2
### Patch Changes
- [#370](https://github.com/esengine/esengine/pull/370) [`18df9d1`](https://github.com/esengine/esengine/commit/18df9d1cda4d4cf3095841d93125f9d41ce214f1) Thanks [@esengine](https://github.com/esengine)! - fix: allow define() to be called before start()
Previously, calling `server.define()` before `server.start()` would throw an error because `roomManager` was initialized inside `start()`. This fix moves the `roomManager` initialization to `createServer()`, allowing the expected usage pattern:
```typescript
const server = await createServer({ port: 3000 });
server.define('world', WorldRoom); // Now works correctly
await server.start();
```
## 1.1.1
### Patch Changes
- [#368](https://github.com/esengine/esengine/pull/368) [`66d5dc2`](https://github.com/esengine/esengine/commit/66d5dc27f740cc81b0645bde61dabf665743a5a0) Thanks [@esengine](https://github.com/esengine)! - fix: 修复发布缺少 dist 目录 | fix missing dist in published packages
## 1.1.0
### Minor Changes
- [#366](https://github.com/esengine/esengine/pull/366) [`b6f1235`](https://github.com/esengine/esengine/commit/b6f1235239c049abc62b6827554eb941e73dae65) Thanks [@esengine](https://github.com/esengine)! - feat(server): 添加游戏服务器框架与房间系统 | add game server framework with Room system
**@esengine/server** - 游戏服务器框架 | Game server framework
- 文件路由系统 | File-based routing
- Room 生命周期管理 (onCreate, onJoin, onLeave, onTick, onDispose) | Room lifecycle management
- `@onMessage` 装饰器处理消息 | Message handler decorator
- 玩家管理与断线处理 | Player management with auto-disconnect
- 内置 JoinRoom/LeaveRoom API | Built-in room APIs
- defineApi/defineMsg 类型安全辅助函数 | Type-safe helper functions
**create-esengine-server** - CLI 脚手架工具 | CLI scaffolding tool
- 生成 shared/server/client 项目结构 | Creates project structure
- 类型安全的协议定义 | Type-safe protocol definitions
- 包含 GameRoom 示例实现 | Includes example implementation

View File

@@ -0,0 +1,21 @@
{
"id": "server",
"name": "@esengine/server",
"globalKey": "server",
"displayName": "Game Server",
"description": "Game server framework with file-based routing | 基于文件路由的游戏服务器框架",
"version": "1.0.0",
"category": "Network",
"icon": "Server",
"tags": ["server", "rpc", "websocket", "network"],
"isCore": false,
"defaultEnabled": false,
"isEngineModule": true,
"canContainContent": false,
"platforms": ["nodejs"],
"dependencies": ["rpc"],
"exports": {
"functions": ["createServer", "defineApi", "defineMsg"]
},
"outputPath": "dist/index.js"
}

View File

@@ -0,0 +1,50 @@
{
"name": "@esengine/server",
"version": "1.1.4",
"description": "Game server framework for ESEngine with file-based routing",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist",
"module.json"
],
"scripts": {
"build": "tsup",
"build:watch": "tsup --watch",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"clean": "rimraf dist"
},
"dependencies": {
"@esengine/rpc": "workspace:*"
},
"peerDependencies": {
"ws": ">=8.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/ws": "^8.5.13",
"rimraf": "^5.0.0",
"tsup": "^8.0.0",
"typescript": "^5.7.0",
"ws": "^8.18.0"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"esengine",
"game-server",
"rpc",
"websocket",
"ecs"
]
}

View File

@@ -0,0 +1,253 @@
/**
* @zh 游戏服务器核心
* @en Game server core
*/
import * as path from 'node:path'
import { serve, type RpcServer } from '@esengine/rpc/server'
import { rpc } from '@esengine/rpc'
import type {
ServerConfig,
ServerConnection,
GameServer,
ApiContext,
MsgContext,
LoadedApiHandler,
LoadedMsgHandler,
} from '../types/index.js'
import { loadApiHandlers, loadMsgHandlers } from '../router/loader.js'
import { RoomManager, type RoomClass, type Room } from '../room/index.js'
/**
* @zh 默认配置
* @en Default configuration
*/
const DEFAULT_CONFIG: Required<Omit<ServerConfig, 'onStart' | 'onConnect' | 'onDisconnect'>> = {
port: 3000,
apiDir: 'src/api',
msgDir: 'src/msg',
tickRate: 20,
}
/**
* @zh 创建游戏服务器
* @en Create game server
*
* @example
* ```typescript
* import { createServer, Room, onMessage } from '@esengine/server'
*
* class GameRoom extends Room {
* onJoin(player) {
* this.broadcast('Joined', { id: player.id })
* }
* }
*
* const server = await createServer({ port: 3000 })
* server.define('game', GameRoom)
* await server.start()
* ```
*/
export async function createServer(config: ServerConfig = {}): Promise<GameServer> {
const opts = { ...DEFAULT_CONFIG, ...config }
const cwd = process.cwd()
// 加载文件路由处理器
const apiHandlers = await loadApiHandlers(path.resolve(cwd, opts.apiDir))
const msgHandlers = await loadMsgHandlers(path.resolve(cwd, opts.msgDir))
if (apiHandlers.length > 0) {
console.log(`[Server] Loaded ${apiHandlers.length} API handlers`)
}
if (msgHandlers.length > 0) {
console.log(`[Server] Loaded ${msgHandlers.length} message handlers`)
}
// 动态构建协议
const apiDefs: Record<string, ReturnType<typeof rpc.api>> = {
// 内置 API
JoinRoom: rpc.api(),
LeaveRoom: rpc.api(),
}
const msgDefs: Record<string, ReturnType<typeof rpc.msg>> = {
// 内置消息(房间消息透传)
RoomMessage: rpc.msg(),
}
for (const handler of apiHandlers) {
apiDefs[handler.name] = rpc.api()
}
for (const handler of msgHandlers) {
msgDefs[handler.name] = rpc.msg()
}
const protocol = rpc.define({
api: apiDefs,
msg: msgDefs,
})
// 服务器状态
let currentTick = 0
let tickInterval: ReturnType<typeof setInterval> | null = null
let rpcServer: RpcServer<typeof protocol, Record<string, unknown>> | null = null
// 房间管理器(立即初始化,以便 define() 可在 start() 前调用)
const roomManager = new RoomManager((conn, type, data) => {
rpcServer?.send(conn, 'RoomMessage' as any, { type, data } as any)
})
// 构建 API 处理器映射
const apiMap: Record<string, LoadedApiHandler> = {}
for (const handler of apiHandlers) {
apiMap[handler.name] = handler
}
// 构建消息处理器映射
const msgMap: Record<string, LoadedMsgHandler> = {}
for (const handler of msgHandlers) {
msgMap[handler.name] = handler
}
// 游戏服务器实例
const gameServer: GameServer & {
rooms: RoomManager
} = {
get connections() {
return (rpcServer?.connections ?? []) as ReadonlyArray<ServerConnection>
},
get tick() {
return currentTick
},
get rooms() {
return roomManager
},
/**
* @zh 注册房间类型
* @en Define room type
*/
define(name: string, roomClass: new () => unknown): void {
roomManager.define(name, roomClass as RoomClass)
},
async start() {
// 构建 API handlers
const apiHandlersObj: Record<string, (input: unknown, conn: any) => Promise<unknown>> = {}
// 内置 JoinRoom API
apiHandlersObj['JoinRoom'] = async (input: any, conn) => {
const { roomType, roomId, options } = input as {
roomType?: string
roomId?: string
options?: Record<string, unknown>
}
if (roomId) {
const result = await roomManager.joinById(roomId, conn.id, conn)
if (!result) {
throw new Error('Failed to join room')
}
return { roomId: result.room.id, playerId: result.player.id }
}
if (roomType) {
const result = await roomManager.joinOrCreate(roomType, conn.id, conn, options)
if (!result) {
throw new Error('Failed to join or create room')
}
return { roomId: result.room.id, playerId: result.player.id }
}
throw new Error('roomType or roomId required')
}
// 内置 LeaveRoom API
apiHandlersObj['LeaveRoom'] = async (_input, conn) => {
await roomManager.leave(conn.id)
return { success: true }
}
// 文件路由 API
for (const [name, handler] of Object.entries(apiMap)) {
apiHandlersObj[name] = async (input, conn) => {
const ctx: ApiContext = {
conn: conn as ServerConnection,
server: gameServer,
}
return handler.definition.handler(input, ctx)
}
}
// 构建消息 handlers
const msgHandlersObj: Record<string, (data: unknown, conn: any) => void | Promise<void>> = {}
// 内置 RoomMessage 处理
msgHandlersObj['RoomMessage'] = async (data: any, conn) => {
const { type, 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
}

View File

@@ -0,0 +1,51 @@
/**
* @zh API 和消息定义助手
* @en API and message definition helpers
*/
import type { ApiDefinition, MsgDefinition } from '../types/index.js'
/**
* @zh 定义 API 处理器
* @en Define API handler
*
* @example
* ```typescript
* // src/api/join.ts
* import { defineApi } from '@esengine/server'
*
* export default defineApi<ReqJoin, ResJoin>({
* handler(req, ctx) {
* ctx.conn.data.playerId = generateId()
* return { playerId: ctx.conn.data.playerId }
* }
* })
* ```
*/
export function defineApi<TReq, TRes, TData = Record<string, unknown>>(
definition: ApiDefinition<TReq, TRes, TData>
): ApiDefinition<TReq, TRes, TData> {
return definition
}
/**
* @zh 定义消息处理器
* @en Define message handler
*
* @example
* ```typescript
* // src/msg/input.ts
* import { defineMsg } from '@esengine/server'
*
* export default defineMsg<MsgInput>({
* handler(msg, ctx) {
* console.log('Input from', ctx.conn.id, msg)
* }
* })
* ```
*/
export function defineMsg<TMsg, TData = Record<string, unknown>>(
definition: MsgDefinition<TMsg, TData>
): MsgDefinition<TMsg, TData> {
return definition
}

View File

@@ -0,0 +1,52 @@
/**
* @zh ESEngine 游戏服务器框架
* @en ESEngine Game Server Framework
*
* @example
* ```typescript
* import { createServer, Room, onMessage } from '@esengine/server'
*
* class GameRoom extends Room {
* maxPlayers = 4
* tickRate = 20
*
* onJoin(player) {
* this.broadcast('Joined', { id: player.id })
* }
*
* @onMessage('Move')
* handleMove(data, player) {
* // handle move
* }
* }
*
* const server = await createServer({ port: 3000 })
* server.define('game', GameRoom)
* await server.start()
* ```
*/
// Core
export { createServer } from './core/server.js'
// Helpers
export { defineApi, defineMsg } from './helpers/define.js'
// Room System
export { Room, type RoomOptions } from './room/Room.js'
export { Player, type IPlayer } from './room/Player.js'
export { onMessage } from './room/decorators.js'
// Types
export type {
ServerConfig,
ServerConnection,
GameServer,
ApiContext,
MsgContext,
ApiDefinition,
MsgDefinition,
} from './types/index.js'
// Re-export useful types from @esengine/rpc
export { RpcError, ErrorCode } from '@esengine/rpc'

View File

@@ -0,0 +1,72 @@
/**
* @zh 玩家类
* @en Player class
*/
import type { Connection } from '@esengine/rpc'
/**
* @zh 玩家接口
* @en Player interface
*/
export interface IPlayer<TData = Record<string, unknown>> {
readonly id: string
readonly roomId: string
data: TData
send<T>(type: string, data: T): void
leave(reason?: string): void
}
/**
* @zh 玩家实现
* @en Player implementation
*/
export class Player<TData = Record<string, unknown>> implements IPlayer<TData> {
readonly id: string
readonly roomId: string
data: TData
private _conn: Connection<any>
private _sendFn: (conn: Connection<any>, type: string, data: unknown) => void
private _leaveFn: (player: Player<TData>, reason?: string) => void
constructor(options: {
id: string
roomId: string
conn: Connection<any>
sendFn: (conn: Connection<any>, type: string, data: unknown) => void
leaveFn: (player: Player<TData>, reason?: string) => void
initialData?: TData
}) {
this.id = options.id
this.roomId = options.roomId
this._conn = options.conn
this._sendFn = options.sendFn
this._leaveFn = options.leaveFn
this.data = options.initialData ?? ({} as TData)
}
/**
* @zh 获取底层连接
* @en Get underlying connection
*/
get connection(): Connection<any> {
return this._conn
}
/**
* @zh 发送消息给玩家
* @en Send message to player
*/
send<T>(type: string, data: T): void {
this._sendFn(this._conn, type, data)
}
/**
* @zh 让玩家离开房间
* @en Make player leave the room
*/
leave(reason?: string): void {
this._leaveFn(this, reason)
}
}

View File

@@ -0,0 +1,383 @@
/**
* @zh 房间基类
* @en Room base class
*/
import { Player } from './Player.js'
/**
* @zh 房间配置
* @en Room options
*/
export interface RoomOptions {
[key: string]: unknown
}
/**
* @zh 消息处理器元数据
* @en Message handler metadata
*/
interface MessageHandlerMeta {
type: string
method: string
}
/**
* @zh 消息处理器存储 key
* @en Message handler storage key
*/
const MESSAGE_HANDLERS = Symbol('messageHandlers')
/**
* @zh 房间基类
* @en Room base class
*
* @example
* ```typescript
* class GameRoom extends Room {
* maxPlayers = 4
* tickRate = 20
*
* onJoin(player: Player) {
* this.broadcast('Joined', { id: player.id })
* }
*
* @onMessage('Move')
* handleMove(data: { x: number, y: number }, player: Player) {
* // handle move
* }
* }
* ```
*/
export abstract class Room<TState = any, TPlayerData = Record<string, unknown>> {
// ========================================================================
// 配置 | Configuration
// ========================================================================
/**
* @zh 最大玩家数
* @en Maximum players
*/
maxPlayers = 16
/**
* @zh Tick 速率每秒0 = 不自动 tick
* @en Tick rate (per second), 0 = no auto tick
*/
tickRate = 0
/**
* @zh 空房间自动销毁
* @en Auto dispose when empty
*/
autoDispose = true
// ========================================================================
// 状态 | State
// ========================================================================
/**
* @zh 房间状态
* @en Room state
*/
state: TState = {} as TState
// ========================================================================
// 内部属性 | Internal properties
// ========================================================================
private _id: string = ''
private _players: Map<string, Player<TPlayerData>> = new Map()
private _locked = false
private _disposed = false
private _tickInterval: ReturnType<typeof setInterval> | null = null
private _lastTickTime = 0
private _broadcastFn: ((type: string, data: unknown) => void) | null = null
private _sendFn: ((conn: any, type: string, data: unknown) => void) | null = null
private _disposeFn: (() => void) | null = null
// ========================================================================
// 只读属性 | Readonly properties
// ========================================================================
/**
* @zh 房间 ID
* @en Room ID
*/
get id(): string {
return this._id
}
/**
* @zh 所有玩家
* @en All players
*/
get players(): ReadonlyArray<Player<TPlayerData>> {
return Array.from(this._players.values())
}
/**
* @zh 玩家数量
* @en Player count
*/
get playerCount(): number {
return this._players.size
}
/**
* @zh 是否已满
* @en Is full
*/
get isFull(): boolean {
return this._players.size >= this.maxPlayers
}
/**
* @zh 是否已锁定
* @en Is locked
*/
get isLocked(): boolean {
return this._locked
}
/**
* @zh 是否已销毁
* @en Is disposed
*/
get isDisposed(): boolean {
return this._disposed
}
// ========================================================================
// 生命周期 | Lifecycle
// ========================================================================
/**
* @zh 房间创建时调用
* @en Called when room is created
*/
onCreate(options?: RoomOptions): void | Promise<void> {}
/**
* @zh 玩家加入时调用
* @en Called when player joins
*/
onJoin(player: Player<TPlayerData>): void | Promise<void> {}
/**
* @zh 玩家离开时调用
* @en Called when player leaves
*/
onLeave(player: Player<TPlayerData>, reason?: string): void | Promise<void> {}
/**
* @zh 游戏循环
* @en Game tick
*/
onTick(dt: number): void {}
/**
* @zh 房间销毁时调用
* @en Called when room is disposed
*/
onDispose(): void | Promise<void> {}
// ========================================================================
// 公共方法 | Public methods
// ========================================================================
/**
* @zh 广播消息给所有玩家
* @en Broadcast message to all players
*/
broadcast<T>(type: string, data: T): void {
for (const player of this._players.values()) {
player.send(type, data)
}
}
/**
* @zh 广播消息给除指定玩家外的所有玩家
* @en Broadcast message to all players except one
*/
broadcastExcept<T>(except: Player<TPlayerData>, type: string, data: T): void {
for (const player of this._players.values()) {
if (player.id !== except.id) {
player.send(type, data)
}
}
}
/**
* @zh 获取玩家
* @en Get player by id
*/
getPlayer(id: string): Player<TPlayerData> | undefined {
return this._players.get(id)
}
/**
* @zh 踢出玩家
* @en Kick player
*/
kick(player: Player<TPlayerData>, reason?: string): void {
player.leave(reason ?? 'kicked')
}
/**
* @zh 锁定房间
* @en Lock room
*/
lock(): void {
this._locked = true
}
/**
* @zh 解锁房间
* @en Unlock room
*/
unlock(): void {
this._locked = false
}
/**
* @zh 手动销毁房间
* @en Manually dispose room
*/
dispose(): void {
if (this._disposed) return
this._disposed = true
this._stopTick()
for (const player of this._players.values()) {
player.leave('room_disposed')
}
this._players.clear()
this.onDispose()
this._disposeFn?.()
}
// ========================================================================
// 内部方法 | Internal methods
// ========================================================================
/**
* @internal
*/
_init(options: {
id: string
sendFn: (conn: any, type: string, data: unknown) => void
broadcastFn: (type: string, data: unknown) => void
disposeFn: () => void
}): void {
this._id = options.id
this._sendFn = options.sendFn
this._broadcastFn = options.broadcastFn
this._disposeFn = options.disposeFn
}
/**
* @internal
*/
async _create(options?: RoomOptions): Promise<void> {
await this.onCreate(options)
this._startTick()
}
/**
* @internal
*/
async _addPlayer(id: string, conn: any): Promise<Player<TPlayerData> | null> {
if (this._locked || this.isFull || this._disposed) {
return null
}
const player = new Player<TPlayerData>({
id,
roomId: this._id,
conn,
sendFn: this._sendFn!,
leaveFn: (p, reason) => this._removePlayer(p.id, reason),
})
this._players.set(id, player)
await this.onJoin(player)
return player
}
/**
* @internal
*/
async _removePlayer(id: string, reason?: string): Promise<void> {
const player = this._players.get(id)
if (!player) return
this._players.delete(id)
await this.onLeave(player, reason)
if (this.autoDispose && this._players.size === 0) {
this.dispose()
}
}
/**
* @internal
*/
_handleMessage(type: string, data: unknown, playerId: string): void {
const player = this._players.get(playerId)
if (!player) return
const handlers = (this.constructor as any)[MESSAGE_HANDLERS] as MessageHandlerMeta[] | undefined
if (handlers) {
for (const handler of handlers) {
if (handler.type === type) {
const method = (this as any)[handler.method]
if (typeof method === 'function') {
method.call(this, data, player)
}
}
}
}
}
private _startTick(): void {
if (this.tickRate <= 0) return
this._lastTickTime = performance.now()
this._tickInterval = setInterval(() => {
const now = performance.now()
const dt = (now - this._lastTickTime) / 1000
this._lastTickTime = now
this.onTick(dt)
}, 1000 / this.tickRate)
}
private _stopTick(): void {
if (this._tickInterval) {
clearInterval(this._tickInterval)
this._tickInterval = null
}
}
}
/**
* @zh 获取消息处理器元数据
* @en Get message handler metadata
*/
export function getMessageHandlers(target: any): MessageHandlerMeta[] {
return target[MESSAGE_HANDLERS] || []
}
/**
* @zh 注册消息处理器元数据
* @en Register message handler metadata
*/
export function registerMessageHandler(target: any, type: string, method: string): void {
if (!target[MESSAGE_HANDLERS]) {
target[MESSAGE_HANDLERS] = []
}
target[MESSAGE_HANDLERS].push({ type, method })
}

View File

@@ -0,0 +1,221 @@
/**
* @zh 房间管理器
* @en Room manager
*/
import { Room, type RoomOptions } from './Room.js'
import type { Player } from './Player.js'
/**
* @zh 房间类型
* @en Room class type
*/
export type RoomClass<T extends Room = Room> = new () => T
/**
* @zh 房间定义
* @en Room definition
*/
interface RoomDefinition {
roomClass: RoomClass
}
/**
* @zh 房间管理器
* @en Room manager
*/
export class RoomManager {
private _definitions: Map<string, RoomDefinition> = new Map()
private _rooms: Map<string, Room> = new Map()
private _playerToRoom: Map<string, string> = new Map()
private _nextRoomId = 1
private _sendFn: (conn: any, type: string, data: unknown) => void
constructor(sendFn: (conn: any, type: string, data: unknown) => void) {
this._sendFn = sendFn
}
/**
* @zh 注册房间类型
* @en Define room type
*/
define<T extends Room>(name: string, roomClass: RoomClass<T>): void {
this._definitions.set(name, { roomClass })
}
/**
* @zh 创建房间
* @en Create room
*/
async create(name: string, options?: RoomOptions): Promise<Room | null> {
const def = this._definitions.get(name)
if (!def) {
console.warn(`[RoomManager] Room type not found: ${name}`)
return null
}
const roomId = this._generateRoomId()
const room = new def.roomClass()
room._init({
id: roomId,
sendFn: this._sendFn,
broadcastFn: (type, data) => {
for (const player of room.players) {
player.send(type, data)
}
},
disposeFn: () => {
this._rooms.delete(roomId)
},
})
this._rooms.set(roomId, room)
await room._create(options)
console.log(`[Room] Created: ${name} (${roomId})`)
return room
}
/**
* @zh 加入或创建房间
* @en Join or create room
*/
async joinOrCreate(
name: string,
playerId: string,
conn: any,
options?: RoomOptions
): Promise<{ room: Room; player: Player } | null> {
// 查找可加入的房间
let room = this._findAvailableRoom(name)
// 没有则创建
if (!room) {
room = await this.create(name, options)
if (!room) return null
}
// 加入房间
const player = await room._addPlayer(playerId, conn)
if (!player) return null
this._playerToRoom.set(playerId, room.id)
console.log(`[Room] Player ${playerId} joined ${room.id}`)
return { room, player }
}
/**
* @zh 加入指定房间
* @en Join specific room
*/
async joinById(
roomId: string,
playerId: string,
conn: any
): Promise<{ room: Room; player: Player } | null> {
const room = this._rooms.get(roomId)
if (!room) return null
const player = await room._addPlayer(playerId, conn)
if (!player) return null
this._playerToRoom.set(playerId, room.id)
console.log(`[Room] Player ${playerId} joined ${room.id}`)
return { room, player }
}
/**
* @zh 玩家离开
* @en Player leave
*/
async leave(playerId: string, reason?: string): Promise<void> {
const roomId = this._playerToRoom.get(playerId)
if (!roomId) return
const room = this._rooms.get(roomId)
if (room) {
await room._removePlayer(playerId, reason)
}
this._playerToRoom.delete(playerId)
console.log(`[Room] Player ${playerId} left ${roomId}`)
}
/**
* @zh 处理消息
* @en Handle message
*/
handleMessage(playerId: string, type: string, data: unknown): void {
const roomId = this._playerToRoom.get(playerId)
if (!roomId) return
const room = this._rooms.get(roomId)
if (room) {
room._handleMessage(type, data, playerId)
}
}
/**
* @zh 获取房间
* @en Get room
*/
getRoom(roomId: string): Room | undefined {
return this._rooms.get(roomId)
}
/**
* @zh 获取玩家所在房间
* @en Get player's room
*/
getPlayerRoom(playerId: string): Room | undefined {
const roomId = this._playerToRoom.get(playerId)
return roomId ? this._rooms.get(roomId) : undefined
}
/**
* @zh 获取所有房间
* @en Get all rooms
*/
getRooms(): ReadonlyArray<Room> {
return Array.from(this._rooms.values())
}
/**
* @zh 获取指定类型的所有房间
* @en Get all rooms of a type
*/
getRoomsByType(name: string): Room[] {
const def = this._definitions.get(name)
if (!def) return []
return Array.from(this._rooms.values()).filter(
room => room instanceof def.roomClass
)
}
private _findAvailableRoom(name: string): Room | undefined {
const def = this._definitions.get(name)
if (!def) return undefined
for (const room of this._rooms.values()) {
if (
room instanceof def.roomClass &&
!room.isFull &&
!room.isLocked &&
!room.isDisposed
) {
return room
}
}
return undefined
}
private _generateRoomId(): string {
return `room_${this._nextRoomId++}`
}
}

View File

@@ -0,0 +1,35 @@
/**
* @zh 房间装饰器
* @en Room decorators
*/
import { registerMessageHandler } from './Room.js'
/**
* @zh 消息处理器装饰器
* @en Message handler decorator
*
* @example
* ```typescript
* class GameRoom extends Room {
* @onMessage('Move')
* handleMove(data: { x: number, y: number }, player: Player) {
* // handle move
* }
*
* @onMessage('Chat')
* handleChat(data: { text: string }, player: Player) {
* this.broadcast('Chat', { from: player.id, text: data.text })
* }
* }
* ```
*/
export function onMessage(type: string): MethodDecorator {
return function (
target: any,
propertyKey: string | symbol,
_descriptor: PropertyDescriptor
) {
registerMessageHandler(target.constructor, type, propertyKey as string)
}
}

View File

@@ -0,0 +1,9 @@
/**
* @zh 房间系统
* @en Room system
*/
export { Room, type RoomOptions } from './Room.js'
export { Player, type IPlayer } from './Player.js'
export { RoomManager, type RoomClass } from './RoomManager.js'
export { onMessage } from './decorators.js'

View File

@@ -0,0 +1,112 @@
/**
* @zh 文件路由加载器
* @en File-based router loader
*/
import * as fs from 'node:fs'
import * as path from 'node:path'
import { pathToFileURL } from 'node:url'
import type { ApiDefinition, MsgDefinition, LoadedApiHandler, LoadedMsgHandler } from '../types/index.js'
/**
* @zh 将文件名转换为 API/消息名称
* @en Convert filename to API/message name
*
* @example
* 'join.ts' -> 'Join'
* 'spawn-agent.ts' -> 'SpawnAgent'
* 'save_blueprint.ts' -> 'SaveBlueprint'
*/
function fileNameToHandlerName(fileName: string): string {
const baseName = fileName.replace(/\.(ts|js|mts|mjs)$/, '')
return baseName
.split(/[-_]/)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
}
/**
* @zh 扫描目录获取所有处理器文件
* @en Scan directory for all handler files
*/
function scanDirectory(dir: string): string[] {
if (!fs.existsSync(dir)) {
return []
}
const files: string[] = []
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isFile() && /\.(ts|js|mts|mjs)$/.test(entry.name)) {
// 跳过 index 和下划线开头的文件
if (entry.name.startsWith('_') || entry.name.startsWith('index.')) {
continue
}
files.push(path.join(dir, entry.name))
}
}
return files
}
/**
* @zh 加载 API 处理器
* @en Load API handlers
*/
export async function loadApiHandlers(apiDir: string): Promise<LoadedApiHandler[]> {
const files = scanDirectory(apiDir)
const handlers: LoadedApiHandler[] = []
for (const filePath of files) {
try {
const fileUrl = pathToFileURL(filePath).href
const module = await import(fileUrl)
const definition = module.default as ApiDefinition<unknown, unknown, unknown>
if (definition && typeof definition.handler === 'function') {
const name = fileNameToHandlerName(path.basename(filePath))
handlers.push({
name,
path: filePath,
definition,
})
}
} catch (err) {
console.warn(`[Server] Failed to load API handler: ${filePath}`, err)
}
}
return handlers
}
/**
* @zh 加载消息处理器
* @en Load message handlers
*/
export async function loadMsgHandlers(msgDir: string): Promise<LoadedMsgHandler[]> {
const files = scanDirectory(msgDir)
const handlers: LoadedMsgHandler[] = []
for (const filePath of files) {
try {
const fileUrl = pathToFileURL(filePath).href
const module = await import(fileUrl)
const definition = module.default as MsgDefinition<unknown, unknown>
if (definition && typeof definition.handler === 'function') {
const name = fileNameToHandlerName(path.basename(filePath))
handlers.push({
name,
path: filePath,
definition,
})
}
} catch (err) {
console.warn(`[Server] Failed to load msg handler: ${filePath}`, err)
}
}
return handlers
}

View File

@@ -0,0 +1,234 @@
/**
* @zh ESEngine Server 类型定义
* @en ESEngine Server type definitions
*/
import type { Connection, ProtocolDef } from '@esengine/rpc'
// ============================================================================
// Server Config
// ============================================================================
/**
* @zh 服务器配置
* @en Server configuration
*/
export interface ServerConfig {
/**
* @zh 监听端口
* @en Listen port
* @default 3000
*/
port?: number
/**
* @zh API 目录路径
* @en API directory path
* @default 'src/api'
*/
apiDir?: string
/**
* @zh 消息处理器目录路径
* @en Message handlers directory path
* @default 'src/msg'
*/
msgDir?: string
/**
* @zh 游戏 Tick 速率 (每秒)
* @en Game tick rate (per second)
* @default 20
*/
tickRate?: number
/**
* @zh 服务器启动回调
* @en Server start callback
*/
onStart?: (port: number) => void
/**
* @zh 连接建立回调
* @en Connection established callback
*/
onConnect?: (conn: ServerConnection) => void | Promise<void>
/**
* @zh 连接断开回调
* @en Connection closed callback
*/
onDisconnect?: (conn: ServerConnection) => void | Promise<void>
}
// ============================================================================
// Connection
// ============================================================================
/**
* @zh 服务器连接(扩展 RPC Connection
* @en Server connection (extends RPC Connection)
*/
export interface ServerConnection<TData = Record<string, unknown>> extends Connection<TData> {
/**
* @zh 连接唯一标识(继承自 Connection
* @en Connection unique identifier (inherited from Connection)
*/
readonly id: string
/**
* @zh 用户自定义数据
* @en User-defined data
*/
data: TData
}
// ============================================================================
// API Definition
// ============================================================================
/**
* @zh API 处理器上下文
* @en API handler context
*/
export interface ApiContext<TData = Record<string, unknown>> {
/**
* @zh 当前连接
* @en Current connection
*/
conn: ServerConnection<TData>
/**
* @zh 服务器实例
* @en Server instance
*/
server: GameServer
}
/**
* @zh API 定义选项
* @en API definition options
*/
export interface ApiDefinition<TReq = unknown, TRes = unknown, TData = Record<string, unknown>> {
/**
* @zh API 处理函数
* @en API handler function
*/
handler: (req: TReq, ctx: ApiContext<TData>) => TRes | Promise<TRes>
/**
* @zh 请求验证函数(可选)
* @en Request validation function (optional)
*/
validate?: (req: unknown) => req is TReq
}
// ============================================================================
// Message Definition
// ============================================================================
/**
* @zh 消息处理器上下文
* @en Message handler context
*/
export interface MsgContext<TData = Record<string, unknown>> {
/**
* @zh 当前连接
* @en Current connection
*/
conn: ServerConnection<TData>
/**
* @zh 服务器实例
* @en Server instance
*/
server: GameServer
}
/**
* @zh 消息定义选项
* @en Message definition options
*/
export interface MsgDefinition<TMsg = unknown, TData = Record<string, unknown>> {
/**
* @zh 消息处理函数
* @en Message handler function
*/
handler: (msg: TMsg, ctx: MsgContext<TData>) => void | Promise<void>
}
// ============================================================================
// Game Server Interface
// ============================================================================
/**
* @zh 游戏服务器接口
* @en Game server interface
*/
export interface GameServer {
/**
* @zh 启动服务器
* @en Start server
*/
start(): Promise<void>
/**
* @zh 停止服务器
* @en Stop server
*/
stop(): Promise<void>
/**
* @zh 广播消息
* @en Broadcast message
*/
broadcast<T>(name: string, data: T): void
/**
* @zh 发送消息给指定连接
* @en Send message to specific connection
*/
send<T>(conn: ServerConnection, name: string, data: T): void
/**
* @zh 获取所有连接
* @en Get all connections
*/
readonly connections: ReadonlyArray<ServerConnection>
/**
* @zh 当前 Tick
* @en Current tick
*/
readonly tick: number
/**
* @zh 注册房间类型
* @en Define room type
*/
define(name: string, roomClass: new () => unknown): void
}
// ============================================================================
// Loaded Handler Types
// ============================================================================
/**
* @zh 已加载的 API 处理器
* @en Loaded API handler
*/
export interface LoadedApiHandler {
name: string
path: string
definition: ApiDefinition<any, any, any>
}
/**
* @zh 已加载的消息处理器
* @en Loaded message handler
*/
export interface LoadedMsgHandler {
name: string
path: string
definition: MsgDefinition<any, any>
}

View File

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

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
external: ['ws', '@esengine/rpc'],
treeshake: true,
})

View File

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

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

View File

@@ -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 };
}
/**
*
*

View File

@@ -51,3 +51,4 @@ export type {
// Module
export { WorldStreamingModule, worldStreamingModule } from './WorldStreamingModule';
export type { IWorldStreamingSetupOptions } from './WorldStreamingModule';

View File

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

View File

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

View File

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

View File

@@ -1,17 +0,0 @@
# @esengine/network-server
## 1.0.2
### Patch Changes
- [#354](https://github.com/esengine/esengine/pull/354) [`1e240e8`](https://github.com/esengine/esengine/commit/1e240e86f2f75672c3609c9d86238a9ec08ebb4e) Thanks [@esengine](https://github.com/esengine)! - feat(cli): 增强 Node.js 服务端适配器
**@esengine/cli:**
- 添加 @esengine/network-server 依赖支持
- 生成完整的 ECS 游戏服务器项目结构
- 组件使用 @ECSComponent 装饰器注册
- tsconfig 启用 experimentalDecorators
**@esengine/network-server:**
- 支持 ESM/CJS 双格式导出
- 添加 ws@8.18.0 解决 Node.js 24 兼容性问题

View File

@@ -1,47 +0,0 @@
{
"name": "@esengine/network-server",
"version": "1.0.2",
"description": "TSRPC-based network server for ESEngine",
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsx src/main.ts",
"start": "node dist/main.js"
},
"dependencies": {
"@esengine/network-protocols": "workspace:*",
"tsrpc": "^3.4.15",
"ws": "^8.18.0"
},
"devDependencies": {
"tsup": "^8.5.1",
"tsx": "^4.19.0"
},
"keywords": [
"esengine",
"network",
"server",
"tsrpc",
"websocket"
],
"author": "",
"license": "MIT"
}

View File

@@ -1,31 +0,0 @@
/**
* @esengine/network-server
*
* 基于 TSRPC 的网络服务器模块
* TSRPC-based network server module
*/
// ============================================================================
// Re-export from protocols | 从协议包重新导出
// ============================================================================
export type {
ServiceType,
IEntityState,
IPlayerInput,
MsgSync,
MsgInput,
MsgSpawn,
MsgDespawn,
ReqJoin,
ResJoin
} from '@esengine/network-protocols';
export { serviceProto } from '@esengine/network-protocols';
// ============================================================================
// Server | 服务器
// ============================================================================
export { GameServer, type IServerConfig } from './services/GameServer';
export { Room, type IPlayer, type IRoomConfig } from './services/Room';

View File

@@ -1,35 +0,0 @@
/**
* 服务器入口
* Server entry point
*/
import { GameServer } from './services/GameServer';
const PORT = parseInt(process.env['PORT'] ?? '3000', 10);
const server = new GameServer({
port: PORT,
roomConfig: {
maxPlayers: 16,
tickRate: 20
}
});
// 启动服务器
// Start server
server.start().catch((err) => {
console.error('[Main] 服务器启动失败 | Server failed to start:', err);
process.exit(1);
});
// 优雅关闭
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\n[Main] 正在关闭服务器... | Shutting down server...');
await server.stop();
process.exit(0);
});
process.on('SIGTERM', async () => {
await server.stop();
process.exit(0);
});

View File

@@ -1,197 +0,0 @@
import { WsServer, type BaseConnection } from 'tsrpc';
import { serviceProto, type ServiceType, type MsgInput } from '@esengine/network-protocols';
import { Room, type IRoomConfig } from './Room';
/**
* 服务器配置
* Server configuration
*/
export interface IServerConfig {
port: number;
roomConfig?: Partial<IRoomConfig>;
}
const DEFAULT_CONFIG: IServerConfig = {
port: 3000
};
/**
* 游戏服务器
* Game server
*
* 管理 WebSocket 连接和房间。
* Manages WebSocket connections and rooms.
*/
export class GameServer {
private _server: WsServer<ServiceType>;
private _config: IServerConfig;
private _rooms: Map<string, Room> = new Map();
private _connectionToRoom: Map<BaseConnection<ServiceType>, { roomId: string; clientId: number }> = new Map();
constructor(config: Partial<IServerConfig> = {}) {
this._config = { ...DEFAULT_CONFIG, ...config };
this._server = new WsServer(serviceProto, {
port: this._config.port,
json: true,
logLevel: 'info'
});
this._setupHandlers();
}
/**
* 启动服务器
* Start server
*/
async start(): Promise<void> {
await this._server.start();
console.log(`[GameServer] 服务器已启动 | Server started on port ${this._config.port}`);
}
/**
* 停止服务器
* Stop server
*/
async stop(): Promise<void> {
// 销毁所有房间
// Destroy all rooms
for (const room of this._rooms.values()) {
room.destroy();
}
this._rooms.clear();
this._connectionToRoom.clear();
await this._server.stop();
console.log('[GameServer] 服务器已停止 | Server stopped');
}
/**
* 获取或创建房间
* Get or create room
*/
getOrCreateRoom(roomId?: string): Room {
// 如果没有指定房间 ID寻找未满的房间或创建新房间
// If no room ID specified, find a non-full room or create new one
if (!roomId) {
for (const room of this._rooms.values()) {
if (!room.isFull) {
return room;
}
}
roomId = this._generateRoomId();
}
let room = this._rooms.get(roomId);
if (!room) {
room = new Room(roomId, this._config.roomConfig);
this._rooms.set(roomId, room);
console.log(`[GameServer] 创建房间 | Room created: ${roomId}`);
}
return room;
}
/**
* 获取房间
* Get room
*/
getRoom(roomId: string): Room | undefined {
return this._rooms.get(roomId);
}
/**
* 获取连接的房间信息
* Get connection's room info
*/
getConnectionInfo(connection: BaseConnection<ServiceType>): { roomId: string; clientId: number } | undefined {
return this._connectionToRoom.get(connection);
}
/**
* 设置连接的房间信息
* Set connection's room info
*/
setConnectionInfo(connection: BaseConnection<ServiceType>, roomId: string, clientId: number): void {
this._connectionToRoom.set(connection, { roomId, clientId });
}
private _setupHandlers(): void {
// 处理加入请求
// Handle join request
this._server.implementApi('Join', async (call) => {
const { playerName, roomId } = call.req;
const room = this.getOrCreateRoom(roomId);
if (room.isFull) {
call.error('房间已满 | Room is full');
return;
}
const player = room.addPlayer(playerName, call.conn);
if (!player) {
call.error('加入房间失败 | Failed to join room');
return;
}
this.setConnectionInfo(call.conn, room.id, player.clientId);
// 向新玩家发送自己的生成消息
// Send spawn message to new player for themselves
call.conn.sendMsg('Spawn', {
netId: player.netId,
ownerId: player.clientId,
prefab: 'player',
pos: { x: 0, y: 0 },
rot: 0
});
call.succ({
clientId: player.clientId,
roomId: room.id,
playerCount: room.playerCount
});
console.log(`[GameServer] 玩家加入 | Player joined: ${playerName} (${player.clientId}) -> ${room.id}`);
});
// 处理输入消息
// Handle input message
this._server.listenMsg('Input', (call) => {
const info = this.getConnectionInfo(call.conn);
if (!info) return;
const room = this.getRoom(info.roomId);
if (!room) return;
const msg = call.msg as MsgInput;
room.handleInput(info.clientId, msg.input);
});
// 处理断开连接
// Handle disconnect
this._server.flows.postDisconnectFlow.push((v) => {
const info = this._connectionToRoom.get(v.conn);
if (info) {
const room = this.getRoom(info.roomId);
if (room) {
room.removePlayer(info.clientId);
console.log(`[GameServer] 玩家离开 | Player left: ${info.clientId} from ${info.roomId}`);
// 如果房间空了,删除房间
// If room is empty, delete it
if (room.playerCount === 0) {
room.destroy();
this._rooms.delete(info.roomId);
console.log(`[GameServer] 删除空房间 | Empty room deleted: ${info.roomId}`);
}
}
this._connectionToRoom.delete(v.conn);
}
return v;
});
}
private _generateRoomId(): string {
return Math.random().toString(36).substring(2, 8).toUpperCase();
}
}

View File

@@ -1,234 +0,0 @@
import type { BaseConnection } from 'tsrpc';
import type { ServiceType, IEntityState } from '@esengine/network-protocols';
/**
* 连接类型别名
* Connection type alias
*/
type Connection = BaseConnection<ServiceType>;
/**
* 玩家信息
* Player information
*/
export interface IPlayer {
clientId: number;
name: string;
connection: Connection;
netId: number;
}
/**
* 房间配置
* Room configuration
*/
export interface IRoomConfig {
maxPlayers: number;
tickRate: number;
}
const DEFAULT_CONFIG: IRoomConfig = {
maxPlayers: 16,
tickRate: 20
};
/**
* 游戏房间
* Game room
*
* 管理房间内的玩家和实体状态同步。
* Manages players and entity state synchronization within a room.
*/
export class Room {
private _id: string;
private _config: IRoomConfig;
private _players: Map<number, IPlayer> = new Map();
private _entities: Map<number, IEntityState> = new Map();
private _nextClientId: number = 1;
private _nextNetId: number = 1;
private _syncInterval: ReturnType<typeof setInterval> | null = null;
constructor(id: string, config: Partial<IRoomConfig> = {}) {
this._id = id;
this._config = { ...DEFAULT_CONFIG, ...config };
}
get id(): string {
return this._id;
}
get playerCount(): number {
return this._players.size;
}
get isFull(): boolean {
return this._players.size >= this._config.maxPlayers;
}
/**
* 添加玩家
* Add player
*/
addPlayer(name: string, connection: Connection): IPlayer | null {
if (this.isFull) {
return null;
}
const clientId = this._nextClientId++;
const netId = this._nextNetId++;
const player: IPlayer = {
clientId,
name,
connection,
netId
};
this._players.set(clientId, player);
// 创建玩家实体
// Create player entity
const entityState: IEntityState = {
netId,
pos: { x: 0, y: 0 },
rot: 0
};
this._entities.set(netId, entityState);
// 通知其他玩家
// Notify other players
this._broadcastSpawn(player, entityState);
// 同步现有实体给新玩家
// Sync existing entities to new player
this._syncExistingEntities(player);
// 启动同步循环
// Start sync loop
if (this._syncInterval === null) {
this._startSyncLoop();
}
return player;
}
/**
* 移除玩家
* Remove player
*/
removePlayer(clientId: number): void {
const player = this._players.get(clientId);
if (!player) return;
this._players.delete(clientId);
this._entities.delete(player.netId);
// 通知其他玩家
// Notify other players
this._broadcastDespawn(player.netId);
// 停止同步循环
// Stop sync loop
if (this._players.size === 0 && this._syncInterval !== null) {
clearInterval(this._syncInterval);
this._syncInterval = null;
}
}
/**
* 处理玩家输入
* Handle player input
*/
handleInput(
clientId: number,
input: { moveDir?: { x: number; y: number }; actions?: string[] }
): void {
const player = this._players.get(clientId);
if (!player) return;
const entity = this._entities.get(player.netId);
if (!entity || !entity.pos) return;
// 简单的移动处理
// Simple movement handling
if (input.moveDir) {
const speed = 5;
entity.pos.x += input.moveDir.x * speed;
entity.pos.y += input.moveDir.y * speed;
}
}
/**
* 获取玩家
* Get player
*/
getPlayer(clientId: number): IPlayer | undefined {
return this._players.get(clientId);
}
/**
* 销毁房间
* Destroy room
*/
destroy(): void {
if (this._syncInterval !== null) {
clearInterval(this._syncInterval);
this._syncInterval = null;
}
this._players.clear();
this._entities.clear();
}
private _startSyncLoop(): void {
const interval = 1000 / this._config.tickRate;
this._syncInterval = setInterval(() => {
this._broadcastSync();
}, interval);
}
private _broadcastSync(): void {
if (this._players.size === 0) return;
const entities = Array.from(this._entities.values());
const time = Date.now();
for (const player of this._players.values()) {
player.connection.sendMsg('Sync', { time, entities });
}
}
private _broadcastSpawn(newPlayer: IPlayer, state: IEntityState): void {
for (const player of this._players.values()) {
if (player.clientId === newPlayer.clientId) continue;
player.connection.sendMsg('Spawn', {
netId: state.netId,
ownerId: newPlayer.clientId,
prefab: 'player',
pos: state.pos ?? { x: 0, y: 0 },
rot: state.rot ?? 0
});
}
}
private _broadcastDespawn(netId: number): void {
for (const player of this._players.values()) {
player.connection.sendMsg('Despawn', { netId });
}
}
private _syncExistingEntities(newPlayer: IPlayer): void {
for (const [netId, state] of this._entities) {
const owner = Array.from(this._players.values()).find((p) => p.netId === netId);
if (!owner || owner.clientId === newPlayer.clientId) continue;
newPlayer.connection.sendMsg('Spawn', {
netId,
ownerId: owner.clientId,
prefab: 'player',
pos: state.pos ?? { x: 0, y: 0 },
rot: state.rot ?? 0
});
}
}
}

View File

@@ -1,13 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@@ -1,21 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"moduleResolution": "node"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
],
"references": [
{
"path": "../../network-ext/network-protocols"
}
]
}

View File

@@ -1,11 +0,0 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts', 'src/main.ts'],
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
clean: true,
external: ['tsrpc'],
tsconfig: 'tsconfig.build.json'
});

View File

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

View File

@@ -0,0 +1,26 @@
# create-esengine-server
## 1.1.1
### Patch Changes
- [#368](https://github.com/esengine/esengine/pull/368) [`66d5dc2`](https://github.com/esengine/esengine/commit/66d5dc27f740cc81b0645bde61dabf665743a5a0) Thanks [@esengine](https://github.com/esengine)! - fix: 修复发布缺少 dist 目录 | fix missing dist in published packages
## 1.1.0
### Minor Changes
- [#366](https://github.com/esengine/esengine/pull/366) [`b6f1235`](https://github.com/esengine/esengine/commit/b6f1235239c049abc62b6827554eb941e73dae65) Thanks [@esengine](https://github.com/esengine)! - feat(server): 添加游戏服务器框架与房间系统 | add game server framework with Room system
**@esengine/server** - 游戏服务器框架 | Game server framework
- 文件路由系统 | File-based routing
- Room 生命周期管理 (onCreate, onJoin, onLeave, onTick, onDispose) | Room lifecycle management
- `@onMessage` 装饰器处理消息 | Message handler decorator
- 玩家管理与断线处理 | Player management with auto-disconnect
- 内置 JoinRoom/LeaveRoom API | Built-in room APIs
- defineApi/defineMsg 类型安全辅助函数 | Type-safe helper functions
**create-esengine-server** - CLI 脚手架工具 | CLI scaffolding tool
- 生成 shared/server/client 项目结构 | Creates project structure
- 类型安全的协议定义 | Type-safe protocol definitions
- 包含 GameRoom 示例实现 | Includes example implementation

View File

@@ -0,0 +1,39 @@
{
"name": "create-esengine-server",
"version": "1.1.1",
"description": "Create ESEngine game server projects",
"type": "module",
"bin": {
"create-esengine-server": "./dist/index.js"
},
"files": [
"dist",
"templates"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"prepublishOnly": "pnpm build"
},
"dependencies": {
"chalk": "^5.3.0",
"commander": "^11.0.0",
"prompts": "^2.4.2"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/prompts": "^2.4.9",
"tsup": "^8.0.0",
"typescript": "^5.7.0"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"esengine",
"create",
"game-server",
"scaffold",
"cli"
]
}

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: false,
clean: true,
sourcemap: false,
banner: {
js: '#!/usr/bin/env node',
},
external: ['chalk', 'commander', 'prompts'],
})

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More