Compare commits

..

3 Commits

Author SHA1 Message Date
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
46 changed files with 3865 additions and 170 deletions

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

@@ -185,7 +185,7 @@ export async function createServer(config: ServerConfig = {}): Promise<GameServe
// 内置 RoomMessage 处理
msgHandlersObj['RoomMessage'] = async (data: any, conn) => {
const { type, payload } = data as { type: string; payload: unknown }
const { type, data: payload } = data as { type: string; data: unknown }
roomManager.handleMessage(conn.id, type, payload)
}

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

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

97
pnpm-lock.yaml generated
View File

@@ -1053,7 +1053,7 @@ importers:
dependencies:
'@esengine/world-streaming':
specifier: workspace:*
version: link:../../../streaming/world-streaming
version: link:../../../framework/world-streaming
devDependencies:
'@esengine/build-config':
specifier: workspace:*
@@ -1763,24 +1763,24 @@ importers:
specifier: ^5.8.3
version: 5.9.3
packages/network-ext/network-server:
packages/framework/world-streaming:
dependencies:
'@esengine/network-protocols':
'@esengine/ecs-framework':
specifier: workspace:*
version: link:../../framework/network-protocols
tsrpc:
specifier: ^3.4.15
version: 3.4.21
ws:
specifier: ^8.18.0
version: 8.18.3
version: link:../core/dist
'@esengine/ecs-framework-math':
specifier: workspace:*
version: link:../math
'@esengine/spatial':
specifier: workspace:*
version: link:../spatial
devDependencies:
tsup:
specifier: ^8.5.1
specifier: ^8.0.1
version: 8.5.1(@microsoft/api-extractor@7.55.2(@types/node@22.19.3))(@swc/core@1.15.7(@swc/helpers@0.5.18))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
tsx:
specifier: ^4.19.0
version: 4.21.0
typescript:
specifier: ^5.3.3
version: 5.9.3
packages/physics/physics-rapier2d:
dependencies:
@@ -2066,22 +2066,6 @@ importers:
specifier: ^5.0.0
version: 5.0.10
packages/streaming/world-streaming:
dependencies:
'@esengine/ecs-framework':
specifier: workspace:*
version: link:../../framework/core/dist
'@esengine/engine-core':
specifier: workspace:*
version: link:../../engine/engine-core
devDependencies:
tsup:
specifier: ^8.0.1
version: 8.5.1(@microsoft/api-extractor@7.55.2(@types/node@22.19.3))(@swc/core@1.15.7(@swc/helpers@0.5.18))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
typescript:
specifier: ^5.3.3
version: 5.9.3
packages/tools/build-config:
devDependencies:
'@vitejs/plugin-react':
@@ -5254,9 +5238,6 @@ packages:
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@types/ws@7.4.7':
resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -10267,9 +10248,6 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsrpc-base-client@2.1.17:
resolution: {integrity: sha512-z3Ei2jgJUWt4Mmf9AWXk0BctXE2FzfVRLitbDgZ9byixjuIQ2rDyDxjR8ae7kiR17Dx4YVIEJ8FW/2180lA5iw==}
tsrpc-cli@2.4.5:
resolution: {integrity: sha512-/3MMyGAAuSnZLQVfoRZXI5sfyGakRTk2AfrllvVSUSfGPTr06iU1YAgOATNYEHl+uAj1+QFz3dKT8g3J+wCIcw==}
hasBin: true
@@ -10277,9 +10255,6 @@ packages:
tsrpc-proto@1.4.3:
resolution: {integrity: sha512-qtkk5i34m9/K1258EdyXAEikU/ADPELHCCXN/oFJ4XwH+kN3kXnKYmwCDblUuMA73V2+A/EwkgUGyAgPa335Hw==}
tsrpc@3.4.21:
resolution: {integrity: sha512-8NdFmEkB5t1raKPuWzwl/qwivbacxsuFzpTDv2nlHPVNBP7fwmIOY6uZT8g/SSaAEUMAKt8fCeOYYtwsvjwjqw==}
tsup@8.5.1:
resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==}
engines: {node: '>=18'}
@@ -10685,10 +10660,6 @@ packages:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
@@ -11005,18 +10976,6 @@ packages:
resolution: {integrity: sha512-v2UQ+50TNf2rNHJ8NyWttfm/EJUBWMJcx6ZTYZr6Qp52uuegWw/lBkCtCbnYZEmPRNL61m+u67dAmGxo+HTULA==}
engines: {node: '>=8'}
ws@7.5.10:
resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==}
engines: {node: '>=8.3.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ^5.0.2
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
engines: {node: '>=10.0.0'}
@@ -14773,10 +14732,6 @@ snapshots:
'@types/web-bluetooth@0.0.21': {}
'@types/ws@7.4.7':
dependencies:
'@types/node': 20.19.27
'@types/ws@8.18.1':
dependencies:
'@types/node': 20.19.27
@@ -21020,13 +20975,6 @@ snapshots:
tslib@2.8.1: {}
tsrpc-base-client@2.1.17:
dependencies:
k8w-extend-native: 1.4.6
tsbuffer: 2.2.23
tslib: 2.8.1
tsrpc-proto: 1.4.3
tsrpc-cli@2.4.5(@swc/core@1.15.7(@swc/helpers@0.5.18))(@swc/wasm@1.15.7)(@types/node@22.19.3):
dependencies:
chalk: 4.1.2
@@ -21055,19 +21003,6 @@ snapshots:
tsbuffer-schema: 2.2.0
tslib: 2.8.1
tsrpc@3.4.21:
dependencies:
'@types/ws': 7.4.7
chalk: 4.1.2
tsbuffer: 2.2.23
tsrpc-base-client: 2.1.17
tsrpc-proto: 1.4.3
uuid: 8.3.2
ws: 7.5.10
transitivePeerDependencies:
- bufferutil
- utf-8-validate
tsup@8.5.1(@microsoft/api-extractor@7.55.2(@types/node@20.19.27))(@swc/core@1.15.7(@swc/helpers@0.5.18))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2):
dependencies:
bundle-require: 5.1.0(esbuild@0.27.2)
@@ -21424,8 +21359,6 @@ snapshots:
uuid@10.0.0: {}
uuid@8.3.2: {}
v8-compile-cache-lib@3.0.1: {}
v8-to-istanbul@9.3.0:
@@ -21814,8 +21747,6 @@ snapshots:
type-fest: 0.4.1
write-json-file: 3.2.0
ws@7.5.10: {}
ws@8.18.3: {}
wsl-utils@0.1.0: