diff --git a/.changeset/pathfinding-update.md b/.changeset/pathfinding-update.md new file mode 100644 index 00000000..1f55a418 --- /dev/null +++ b/.changeset/pathfinding-update.md @@ -0,0 +1,5 @@ +--- +"@esengine/pathfinding": patch +--- + +fix: update pathfinding package to resolve npm version conflict diff --git a/.changeset/world-streaming-refactor.md b/.changeset/world-streaming-refactor.md new file mode 100644 index 00000000..bf534f66 --- /dev/null +++ b/.changeset/world-streaming-refactor.md @@ -0,0 +1,11 @@ +--- +"@esengine/world-streaming": minor +--- + +refactor: move to framework folder and remove engine-core dependency + +- Move from packages/streaming to packages/framework +- Replace TransformComponent with IPositionable interface from @esengine/spatial +- StreamingAnchorComponent now implements IPositionable with x/y properties +- Remove IRuntimeModule dependency, use standalone helper class +- Add IWorldStreamingSetupOptions for configuration diff --git a/docs/src/content/docs/en/modules/index.md b/docs/src/content/docs/en/modules/index.md index 40e01106..5a0523e4 100644 --- a/docs/src/content/docs/en/modules/index.md +++ b/docs/src/content/docs/en/modules/index.md @@ -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 diff --git a/docs/src/content/docs/en/modules/rpc/client.md b/docs/src/content/docs/en/modules/rpc/client.md new file mode 100644 index 00000000..8b8c7484 --- /dev/null +++ b/docs/src/content/docs/en/modules/rpc/client.md @@ -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; + 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 }); + } +} +``` diff --git a/docs/src/content/docs/en/modules/rpc/codec.md b/docs/src/content/docs/en/modules/rpc/codec.md new file mode 100644 index 00000000..03ebabae --- /dev/null +++ b/docs/src/content/docs/en/modules/rpc/codec.md @@ -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' +``` diff --git a/docs/src/content/docs/en/modules/rpc/server.md b/docs/src/content/docs/en/modules/rpc/server.md new file mode 100644 index 00000000..d3a8f038 --- /dev/null +++ b/docs/src/content/docs/en/modules/rpc/server.md @@ -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 { + // Required + port: number; + api: ApiHandlers; + + // Optional + msg?: MsgHandlers; + codec?: Codec; + createConnData?: () => TConnData; + + // Callbacks + onConnect?: (conn: Connection) => void | Promise; + onDisconnect?: (conn: Connection, reason?: string) => void | Promise; + onError?: (error: Error, conn?: Connection) => 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 { + // 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(); + +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(); +``` diff --git a/docs/src/content/docs/en/modules/world-streaming/chunk-manager.md b/docs/src/content/docs/en/modules/world-streaming/chunk-manager.md new file mode 100644 index 00000000..69f0c385 --- /dev/null +++ b/docs/src/content/docs/en/modules/world-streaming/chunk-manager.md @@ -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 { + // Load from database, file, or procedural generation + const data = await fetchChunkFromServer(coord); + return data; + } + + async saveChunkData(data: IChunkData): Promise { + // Save modified chunks + await saveChunkToServer(data); + } +} +``` + +## Cleanup + +```typescript +// Unload all chunks +chunkManager.clear(); + +// Full disposal (implements IService) +chunkManager.dispose(); +``` diff --git a/docs/src/content/docs/en/modules/world-streaming/examples.md b/docs/src/content/docs/en/modules/world-streaming/examples.md new file mode 100644 index 00000000..b9dd4dbc --- /dev/null +++ b/docs/src/content/docs/en/modules/world-streaming/examples.md @@ -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 { + 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 { + // 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(); + + constructor(db: Database) { + this.db = db; + } + + async loadChunkData(coord: IChunkCoord): Promise { + 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 { + 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>(); + + async updatePlayerPosition(playerId: string, x: number, y: number) { + const centerCoord = this.chunkManager.worldToChunk(x, y); + const loadRadius = 2; + + const newChunks = new Set(); + + // 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 { + 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): 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; + } +} +``` diff --git a/docs/src/content/docs/en/modules/world-streaming/index.md b/docs/src/content/docs/en/modules/world-streaming/index.md new file mode 100644 index 00000000..514435cf --- /dev/null +++ b/docs/src/content/docs/en/modules/world-streaming/index.md @@ -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 { + // 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 { + // 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 diff --git a/docs/src/content/docs/en/modules/world-streaming/serialization.md b/docs/src/content/docs/en/modules/world-streaming/serialization.md new file mode 100644 index 00000000..0be5a3a2 --- /dev/null +++ b/docs/src/content/docs/en/modules/world-streaming/serialization.md @@ -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 { + const data: Record = {}; + + 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): 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; // 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 { + 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 { + 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 { + 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; + } +} +``` diff --git a/docs/src/content/docs/en/modules/world-streaming/streaming-system.md b/docs/src/content/docs/en/modules/world-streaming/streaming-system.md new file mode 100644 index 00000000..fcc68183 --- /dev/null +++ b/docs/src/content/docs/en/modules/world-streaming/streaming-system.md @@ -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 + } +}); +``` diff --git a/docs/src/content/docs/modules/index.md b/docs/src/content/docs/modules/index.md index 6ef2c8df..6c0c8f1e 100644 --- a/docs/src/content/docs/modules/index.md +++ b/docs/src/content/docs/modules/index.md @@ -28,6 +28,7 @@ ESEngine 提供了丰富的功能模块,可以按需引入到你的项目中 |------|------|------| | [可视化脚本](/modules/blueprint/) | `@esengine/blueprint` | 蓝图可视化脚本系统 | | [程序化生成](/modules/procgen/) | `@esengine/procgen` | 噪声函数、随机工具 | +| [世界流式加载](/modules/world-streaming/) | `@esengine/world-streaming` | 开放世界区块流式加载 | ### 网络模块 diff --git a/docs/src/content/docs/modules/rpc/client.md b/docs/src/content/docs/modules/rpc/client.md new file mode 100644 index 00000000..fa507d1a --- /dev/null +++ b/docs/src/content/docs/modules/rpc/client.md @@ -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; + 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 }); + } +} +``` diff --git a/docs/src/content/docs/modules/rpc/codec.md b/docs/src/content/docs/modules/rpc/codec.md new file mode 100644 index 00000000..2b050694 --- /dev/null +++ b/docs/src/content/docs/modules/rpc/codec.md @@ -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' +``` diff --git a/docs/src/content/docs/modules/rpc/server.md b/docs/src/content/docs/modules/rpc/server.md new file mode 100644 index 00000000..47b328ab --- /dev/null +++ b/docs/src/content/docs/modules/rpc/server.md @@ -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 { + // 必需 + port: number; + api: ApiHandlers; + + // 可选 + msg?: MsgHandlers; + codec?: Codec; + createConnData?: () => TConnData; + + // 回调 + onConnect?: (conn: Connection) => void | Promise; + onDisconnect?: (conn: Connection, reason?: string) => void | Promise; + onError?: (error: Error, conn?: Connection) => 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 { + // 唯一连接 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(); + +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(); +``` diff --git a/docs/src/content/docs/modules/world-streaming/chunk-manager.md b/docs/src/content/docs/modules/world-streaming/chunk-manager.md new file mode 100644 index 00000000..ac781e3f --- /dev/null +++ b/docs/src/content/docs/modules/world-streaming/chunk-manager.md @@ -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 { + // 从数据库、文件或程序化生成加载 + const data = await fetchChunkFromServer(coord); + return data; + } + + async saveChunkData(data: IChunkData): Promise { + // 保存修改过的区块 + await saveChunkToServer(data); + } +} +``` + +## 清理 + +```typescript +// 卸载所有区块 +chunkManager.clear(); + +// 完全释放(实现 IService 接口) +chunkManager.dispose(); +``` diff --git a/docs/src/content/docs/modules/world-streaming/examples.md b/docs/src/content/docs/modules/world-streaming/examples.md new file mode 100644 index 00000000..9531652c --- /dev/null +++ b/docs/src/content/docs/modules/world-streaming/examples.md @@ -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 { + 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 { + // 程序化生成 - 无需持久化 + } + + 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(); + + constructor(db: Database) { + this.db = db; + } + + async loadChunkData(coord: IChunkCoord): Promise { + 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 { + 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>(); + + async updatePlayerPosition(playerId: string, x: number, y: number) { + const centerCoord = this.chunkManager.worldToChunk(x, y); + const loadRadius = 2; + + const newChunks = new Set(); + + // 加载玩家周围的区块 + 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 { + 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): 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; + } +} +``` diff --git a/docs/src/content/docs/modules/world-streaming/index.md b/docs/src/content/docs/modules/world-streaming/index.md new file mode 100644 index 00000000..9936fc3d --- /dev/null +++ b/docs/src/content/docs/modules/world-streaming/index.md @@ -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 { + // 使用种子+坐标生成确定性随机数 + 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 { + // 可选:持久化已修改的区块 + } + + 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 区块 diff --git a/docs/src/content/docs/modules/world-streaming/serialization.md b/docs/src/content/docs/modules/world-streaming/serialization.md new file mode 100644 index 00000000..88ad4b59 --- /dev/null +++ b/docs/src/content/docs/modules/world-streaming/serialization.md @@ -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 { + const data: Record = {}; + + 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): 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; // 组件数据 +} + +interface IChunkCoord { + x: number; // 区块 X 坐标 + y: number; // 区块 Y 坐标 +} +``` + +## 带序列化的数据提供器 + +```typescript +class DatabaseChunkProvider implements IChunkDataProvider { + async loadChunkData(coord: IChunkCoord): Promise { + 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 { + 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 { + 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; + } +} +``` diff --git a/docs/src/content/docs/modules/world-streaming/streaming-system.md b/docs/src/content/docs/modules/world-streaming/streaming-system.md new file mode 100644 index 00000000..9da8047b --- /dev/null +++ b/docs/src/content/docs/modules/world-streaming/streaming-system.md @@ -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) => { + // 清理 - 保存状态,释放资源 + } +}); +``` diff --git a/packages/framework/server/src/core/server.ts b/packages/framework/server/src/core/server.ts index 8569416d..41ea8e92 100644 --- a/packages/framework/server/src/core/server.ts +++ b/packages/framework/server/src/core/server.ts @@ -185,7 +185,7 @@ export async function createServer(config: ServerConfig = {}): Promise { - 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) } diff --git a/packages/streaming/world-streaming/module.json b/packages/framework/world-streaming/module.json similarity index 100% rename from packages/streaming/world-streaming/module.json rename to packages/framework/world-streaming/module.json diff --git a/packages/streaming/world-streaming/package.json b/packages/framework/world-streaming/package.json similarity index 81% rename from packages/streaming/world-streaming/package.json rename to packages/framework/world-streaming/package.json index 766866b8..856489de 100644 --- a/packages/streaming/world-streaming/package.json +++ b/packages/framework/world-streaming/package.json @@ -18,7 +18,8 @@ }, "dependencies": { "@esengine/ecs-framework": "workspace:*", - "@esengine/engine-core": "workspace:*" + "@esengine/ecs-framework-math": "workspace:*", + "@esengine/spatial": "workspace:*" }, "devDependencies": { "tsup": "^8.0.1", @@ -26,7 +27,8 @@ }, "peerDependencies": { "@esengine/ecs-framework": "workspace:*", - "@esengine/engine-core": "workspace:*" + "@esengine/ecs-framework-math": "workspace:*", + "@esengine/spatial": "workspace:*" }, "keywords": [ "ecs", diff --git a/packages/framework/world-streaming/src/WorldStreamingModule.ts b/packages/framework/world-streaming/src/WorldStreamingModule.ts new file mode 100644 index 00000000..4277f8b1 --- /dev/null +++ b/packages/framework/world-streaming/src/WorldStreamingModule.ts @@ -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(); diff --git a/packages/streaming/world-streaming/src/components/ChunkComponent.ts b/packages/framework/world-streaming/src/components/ChunkComponent.ts similarity index 100% rename from packages/streaming/world-streaming/src/components/ChunkComponent.ts rename to packages/framework/world-streaming/src/components/ChunkComponent.ts diff --git a/packages/streaming/world-streaming/src/components/ChunkLoaderComponent.ts b/packages/framework/world-streaming/src/components/ChunkLoaderComponent.ts similarity index 100% rename from packages/streaming/world-streaming/src/components/ChunkLoaderComponent.ts rename to packages/framework/world-streaming/src/components/ChunkLoaderComponent.ts diff --git a/packages/streaming/world-streaming/src/components/StreamingAnchorComponent.ts b/packages/framework/world-streaming/src/components/StreamingAnchorComponent.ts similarity index 64% rename from packages/streaming/world-streaming/src/components/StreamingAnchorComponent.ts rename to packages/framework/world-streaming/src/components/StreamingAnchorComponent.ts index 7c334ff6..f23e0de0 100644 --- a/packages/streaming/world-streaming/src/components/StreamingAnchorComponent.ts +++ b/packages/framework/world-streaming/src/components/StreamingAnchorComponent.ts @@ -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 }; + } /** * 锚点权重 * diff --git a/packages/streaming/world-streaming/src/components/index.ts b/packages/framework/world-streaming/src/components/index.ts similarity index 100% rename from packages/streaming/world-streaming/src/components/index.ts rename to packages/framework/world-streaming/src/components/index.ts diff --git a/packages/streaming/world-streaming/src/index.ts b/packages/framework/world-streaming/src/index.ts similarity index 92% rename from packages/streaming/world-streaming/src/index.ts rename to packages/framework/world-streaming/src/index.ts index 433ea805..3655c0a1 100644 --- a/packages/streaming/world-streaming/src/index.ts +++ b/packages/framework/world-streaming/src/index.ts @@ -51,3 +51,4 @@ export type { // Module export { WorldStreamingModule, worldStreamingModule } from './WorldStreamingModule'; +export type { IWorldStreamingSetupOptions } from './WorldStreamingModule'; diff --git a/packages/streaming/world-streaming/src/services/ChunkManager.ts b/packages/framework/world-streaming/src/services/ChunkManager.ts similarity index 98% rename from packages/streaming/world-streaming/src/services/ChunkManager.ts rename to packages/framework/world-streaming/src/services/ChunkManager.ts index 1e5184e8..7cbf5cc4 100644 --- a/packages/streaming/world-streaming/src/services/ChunkManager.ts +++ b/packages/framework/world-streaming/src/services/ChunkManager.ts @@ -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]; } diff --git a/packages/streaming/world-streaming/src/services/ChunkSerializer.ts b/packages/framework/world-streaming/src/services/ChunkSerializer.ts similarity index 72% rename from packages/streaming/world-streaming/src/services/ChunkSerializer.ts rename to packages/framework/world-streaming/src/services/ChunkSerializer.ts index 2ff8da05..301b8971 100644 --- a/packages/streaming/world-streaming/src/services/ChunkSerializer.ts +++ b/packages/framework/world-streaming/src/services/ChunkSerializer.ts @@ -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); } diff --git a/packages/streaming/world-streaming/src/services/SpatialHashGrid.ts b/packages/framework/world-streaming/src/services/SpatialHashGrid.ts similarity index 100% rename from packages/streaming/world-streaming/src/services/SpatialHashGrid.ts rename to packages/framework/world-streaming/src/services/SpatialHashGrid.ts diff --git a/packages/streaming/world-streaming/src/services/index.ts b/packages/framework/world-streaming/src/services/index.ts similarity index 100% rename from packages/streaming/world-streaming/src/services/index.ts rename to packages/framework/world-streaming/src/services/index.ts diff --git a/packages/streaming/world-streaming/src/systems/ChunkCullingSystem.ts b/packages/framework/world-streaming/src/systems/ChunkCullingSystem.ts similarity index 100% rename from packages/streaming/world-streaming/src/systems/ChunkCullingSystem.ts rename to packages/framework/world-streaming/src/systems/ChunkCullingSystem.ts diff --git a/packages/streaming/world-streaming/src/systems/ChunkStreamingSystem.ts b/packages/framework/world-streaming/src/systems/ChunkStreamingSystem.ts similarity index 93% rename from packages/streaming/world-streaming/src/systems/ChunkStreamingSystem.ts rename to packages/framework/world-streaming/src/systems/ChunkStreamingSystem.ts index e4d34f84..10ed0120 100644 --- a/packages/streaming/world-streaming/src/systems/ChunkStreamingSystem.ts +++ b/packages/framework/world-streaming/src/systems/ChunkStreamingSystem.ts @@ -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 = 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); diff --git a/packages/streaming/world-streaming/src/systems/index.ts b/packages/framework/world-streaming/src/systems/index.ts similarity index 100% rename from packages/streaming/world-streaming/src/systems/index.ts rename to packages/framework/world-streaming/src/systems/index.ts diff --git a/packages/streaming/world-streaming/src/types/ChunkData.ts b/packages/framework/world-streaming/src/types/ChunkData.ts similarity index 100% rename from packages/streaming/world-streaming/src/types/ChunkData.ts rename to packages/framework/world-streaming/src/types/ChunkData.ts diff --git a/packages/streaming/world-streaming/src/types/ChunkState.ts b/packages/framework/world-streaming/src/types/ChunkState.ts similarity index 100% rename from packages/streaming/world-streaming/src/types/ChunkState.ts rename to packages/framework/world-streaming/src/types/ChunkState.ts diff --git a/packages/streaming/world-streaming/src/types/StreamingConfig.ts b/packages/framework/world-streaming/src/types/StreamingConfig.ts similarity index 100% rename from packages/streaming/world-streaming/src/types/StreamingConfig.ts rename to packages/framework/world-streaming/src/types/StreamingConfig.ts diff --git a/packages/streaming/world-streaming/src/types/index.ts b/packages/framework/world-streaming/src/types/index.ts similarity index 100% rename from packages/streaming/world-streaming/src/types/index.ts rename to packages/framework/world-streaming/src/types/index.ts diff --git a/packages/streaming/world-streaming/tsconfig.json b/packages/framework/world-streaming/tsconfig.json similarity index 100% rename from packages/streaming/world-streaming/tsconfig.json rename to packages/framework/world-streaming/tsconfig.json diff --git a/packages/streaming/world-streaming/tsup.config.ts b/packages/framework/world-streaming/tsup.config.ts similarity index 100% rename from packages/streaming/world-streaming/tsup.config.ts rename to packages/framework/world-streaming/tsup.config.ts diff --git a/packages/streaming/world-streaming/src/WorldStreamingModule.ts b/packages/streaming/world-streaming/src/WorldStreamingModule.ts deleted file mode 100644 index d505fe58..00000000 --- a/packages/streaming/world-streaming/src/WorldStreamingModule.ts +++ /dev/null @@ -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(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a9151ec..d3f6d880 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: