feat(world-streaming): 添加世界流式加载系统 (#288)
实现基于区块的世界流式加载系统,支持开放世界游戏: 运行时包 (@esengine/world-streaming): - ChunkComponent: 区块实体组件,包含坐标、边界、状态 - StreamingAnchorComponent: 流式锚点组件(玩家/摄像机) - ChunkLoaderComponent: 流式加载配置组件 - ChunkStreamingSystem: 区块加载/卸载调度系统 - ChunkCullingSystem: 区块可见性剔除系统 - ChunkManager: 区块生命周期管理服务 - SpatialHashGrid: 空间哈希网格 - ChunkSerializer: 区块序列化 编辑器包 (@esengine/world-streaming-editor): - ChunkVisualizer: 区块可视化覆盖层 - ChunkLoaderInspectorProvider: 区块加载器检视器 - StreamingAnchorInspectorProvider: 流式锚点检视器 - WorldStreamingPlugin: 完整插件导出
This commit is contained in:
51
packages/world-streaming-editor/package.json
Normal file
51
packages/world-streaming-editor/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@esengine/world-streaming-editor",
|
||||
"version": "1.0.0",
|
||||
"description": "Editor support for @esengine/world-streaming - chunk visualization and inspector",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"build:watch": "tsup --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/world-streaming": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@esengine/editor-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*",
|
||||
"@esengine/editor-core": "workspace:*",
|
||||
"@esengine/build-config": "workspace:*",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^18.3.1",
|
||||
"zustand": "^5.0.8",
|
||||
"@types/react": "^18.3.12",
|
||||
"rimraf": "^5.0.5",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"world-streaming",
|
||||
"chunk",
|
||||
"editor"
|
||||
],
|
||||
"author": "ESEngine",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 世界流式加载编辑器模块
|
||||
* World Streaming Editor Module
|
||||
*
|
||||
* Registers chunk visualizer, inspector providers and tools for world streaming.
|
||||
*/
|
||||
|
||||
import type { ServiceContainer, Entity } from '@esengine/ecs-framework';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IEditorModuleLoader,
|
||||
PanelDescriptor,
|
||||
EntityCreationTemplate,
|
||||
ComponentInspectorProviderDef,
|
||||
IPlugin,
|
||||
ModuleManifest
|
||||
} from '@esengine/editor-core';
|
||||
import {
|
||||
PanelPosition,
|
||||
InspectorRegistry,
|
||||
EntityStoreService,
|
||||
MessageHub,
|
||||
ComponentRegistry
|
||||
} from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import {
|
||||
ChunkComponent,
|
||||
StreamingAnchorComponent,
|
||||
ChunkLoaderComponent,
|
||||
WorldStreamingModule
|
||||
} from '@esengine/world-streaming';
|
||||
|
||||
import { ChunkLoaderInspectorProvider } from './providers/ChunkLoaderInspectorProvider';
|
||||
import { StreamingAnchorInspectorProvider } from './providers/StreamingAnchorInspectorProvider';
|
||||
|
||||
import './styles/ChunkVisualizer.css';
|
||||
|
||||
/**
|
||||
* 世界流式加载编辑器模块
|
||||
* World Streaming Editor Module
|
||||
*/
|
||||
export class WorldStreamingEditorModule implements IEditorModuleLoader {
|
||||
async install(services: ServiceContainer): Promise<void> {
|
||||
const inspectorRegistry = services.resolve(InspectorRegistry);
|
||||
if (inspectorRegistry) {
|
||||
inspectorRegistry.register(new ChunkLoaderInspectorProvider());
|
||||
inspectorRegistry.register(new StreamingAnchorInspectorProvider());
|
||||
}
|
||||
|
||||
const componentRegistry = services.resolve(ComponentRegistry);
|
||||
if (componentRegistry) {
|
||||
componentRegistry.register({
|
||||
name: 'ChunkLoader',
|
||||
type: ChunkLoaderComponent,
|
||||
category: 'components.category.streaming',
|
||||
description: 'Chunk-based world streaming controller',
|
||||
icon: 'Grid3X3'
|
||||
});
|
||||
|
||||
componentRegistry.register({
|
||||
name: 'StreamingAnchor',
|
||||
type: StreamingAnchorComponent,
|
||||
category: 'components.category.streaming',
|
||||
description: 'Streaming anchor point (player/camera)',
|
||||
icon: 'Anchor'
|
||||
});
|
||||
|
||||
componentRegistry.register({
|
||||
name: 'Chunk',
|
||||
type: ChunkComponent,
|
||||
category: 'components.category.streaming',
|
||||
description: 'Chunk entity marker',
|
||||
icon: 'Square'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
// 清理 | Clean up
|
||||
}
|
||||
|
||||
getPanels(): PanelDescriptor[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
getInspectorProviders(): ComponentInspectorProviderDef[] {
|
||||
return [
|
||||
{
|
||||
componentType: 'ChunkLoader',
|
||||
priority: 100,
|
||||
render: (component, entity, onChange) => {
|
||||
const provider = new ChunkLoaderInspectorProvider();
|
||||
return provider.render(
|
||||
{ entityId: String(entity.id), component },
|
||||
{ target: component, onChange }
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
componentType: 'StreamingAnchor',
|
||||
priority: 100,
|
||||
render: (component, entity, onChange) => {
|
||||
const provider = new StreamingAnchorInspectorProvider();
|
||||
return provider.render(
|
||||
{ entityId: String(entity.id), component },
|
||||
{ target: component, onChange }
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getEntityCreationTemplates(): EntityCreationTemplate[] {
|
||||
return [
|
||||
{
|
||||
id: 'create-streaming-anchor',
|
||||
label: '创建流式锚点',
|
||||
icon: 'Anchor',
|
||||
category: 'streaming',
|
||||
order: 100,
|
||||
create: (): number => {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('Scene not available');
|
||||
}
|
||||
|
||||
const entityStore = Core.services.resolve(EntityStoreService);
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
|
||||
if (!entityStore || !messageHub) {
|
||||
throw new Error('EntityStoreService or MessageHub not available');
|
||||
}
|
||||
|
||||
const anchorCount = entityStore.getAllEntities()
|
||||
.filter((e: Entity) => e.name.startsWith('StreamingAnchor ')).length;
|
||||
const entityName = `StreamingAnchor ${anchorCount + 1}`;
|
||||
|
||||
const entity = scene.createEntity(entityName);
|
||||
entity.addComponent(new TransformComponent());
|
||||
entity.addComponent(new StreamingAnchorComponent());
|
||||
|
||||
entityStore.addEntity(entity);
|
||||
messageHub.publish('entity:added', { entity });
|
||||
messageHub.publish('scene:modified', {});
|
||||
entityStore.selectEntity(entity);
|
||||
|
||||
return entity.id;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'create-chunk-loader',
|
||||
label: '创建区块加载器',
|
||||
icon: 'Grid3X3',
|
||||
category: 'streaming',
|
||||
order: 101,
|
||||
create: (): number => {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('Scene not available');
|
||||
}
|
||||
|
||||
const entityStore = Core.services.resolve(EntityStoreService);
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
|
||||
if (!entityStore || !messageHub) {
|
||||
throw new Error('EntityStoreService or MessageHub not available');
|
||||
}
|
||||
|
||||
const entity = scene.createEntity('ChunkLoader');
|
||||
entity.addComponent(new ChunkLoaderComponent());
|
||||
|
||||
entityStore.addEntity(entity);
|
||||
messageHub.publish('entity:added', { entity });
|
||||
messageHub.publish('scene:modified', {});
|
||||
entityStore.selectEntity(entity);
|
||||
|
||||
return entity.id;
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getFileActionHandlers() {
|
||||
return [];
|
||||
}
|
||||
|
||||
getFileCreationTemplates() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const worldStreamingEditorModule = new WorldStreamingEditorModule();
|
||||
|
||||
/**
|
||||
* 世界流式加载插件清单
|
||||
* World Streaming Plugin Manifest
|
||||
*/
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/world-streaming',
|
||||
name: '@esengine/world-streaming',
|
||||
displayName: 'World Streaming',
|
||||
version: '1.0.0',
|
||||
description: 'Chunk-based world streaming for open world games',
|
||||
category: 'Other',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: false,
|
||||
dependencies: ['engine-core'],
|
||||
exports: {
|
||||
components: ['ChunkComponent', 'StreamingAnchorComponent', 'ChunkLoaderComponent'],
|
||||
systems: ['ChunkStreamingSystem', 'ChunkCullingSystem'],
|
||||
other: ['ChunkManager']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 完整的世界流式加载插件(运行时 + 编辑器)
|
||||
* Complete World Streaming Plugin (runtime + editor)
|
||||
*/
|
||||
export const WorldStreamingPlugin: IPlugin = {
|
||||
manifest,
|
||||
runtimeModule: new WorldStreamingModule(),
|
||||
editorModule: worldStreamingEditorModule
|
||||
};
|
||||
|
||||
export default worldStreamingEditorModule;
|
||||
@@ -0,0 +1,279 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import type { ChunkManager, IChunkCoord, IChunkInfo } from '@esengine/world-streaming';
|
||||
import { EChunkState } from '@esengine/world-streaming';
|
||||
import '../styles/ChunkVisualizer.css';
|
||||
|
||||
export interface ChunkVisualizerProps {
|
||||
chunkManager: ChunkManager | null;
|
||||
cameraX: number;
|
||||
cameraY: number;
|
||||
cameraZoom: number;
|
||||
viewWidth: number;
|
||||
viewHeight: number;
|
||||
bShowCoords?: boolean;
|
||||
bShowStats?: boolean;
|
||||
bShowRadii?: boolean;
|
||||
anchorPositions?: Array<{ x: number; y: number }>;
|
||||
loadRadius?: number;
|
||||
unloadRadius?: number;
|
||||
}
|
||||
|
||||
interface ChunkDisplayInfo {
|
||||
coord: IChunkCoord;
|
||||
state: EChunkState;
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
screenWidth: number;
|
||||
screenHeight: number;
|
||||
isAnchorChunk: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 区块可视化组件
|
||||
*
|
||||
* Chunk visualization overlay for editor viewport.
|
||||
*/
|
||||
export const ChunkVisualizer: React.FC<ChunkVisualizerProps> = ({
|
||||
chunkManager,
|
||||
cameraX,
|
||||
cameraY,
|
||||
cameraZoom,
|
||||
viewWidth,
|
||||
viewHeight,
|
||||
bShowCoords = true,
|
||||
bShowStats = true,
|
||||
bShowRadii = true,
|
||||
anchorPositions = [],
|
||||
loadRadius = 2,
|
||||
unloadRadius = 4
|
||||
}) => {
|
||||
const [chunks, setChunks] = useState<ChunkDisplayInfo[]>([]);
|
||||
const [stats, setStats] = useState({
|
||||
loaded: 0,
|
||||
loading: 0,
|
||||
pendingLoad: 0,
|
||||
pendingUnload: 0
|
||||
});
|
||||
|
||||
const chunkSize = chunkManager?.chunkSize ?? 512;
|
||||
|
||||
const worldToScreen = useCallback((worldX: number, worldY: number) => {
|
||||
const screenX = (worldX - cameraX) * cameraZoom + viewWidth / 2;
|
||||
const screenY = (worldY - cameraY) * cameraZoom + viewHeight / 2;
|
||||
return { x: screenX, y: screenY };
|
||||
}, [cameraX, cameraY, cameraZoom, viewWidth, viewHeight]);
|
||||
|
||||
const anchorChunkCoords = useMemo(() => {
|
||||
return anchorPositions.map(pos => ({
|
||||
x: Math.floor(pos.x / chunkSize),
|
||||
y: Math.floor(pos.y / chunkSize)
|
||||
}));
|
||||
}, [anchorPositions, chunkSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chunkManager) {
|
||||
setChunks([]);
|
||||
setStats({ loaded: 0, loading: 0, pendingLoad: 0, pendingUnload: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const updateChunks = () => {
|
||||
const visibleChunks: ChunkDisplayInfo[] = [];
|
||||
let loadedCount = 0;
|
||||
let loadingCount = 0;
|
||||
|
||||
const halfViewW = viewWidth / 2 / cameraZoom;
|
||||
const halfViewH = viewHeight / 2 / cameraZoom;
|
||||
const margin = chunkSize * 2;
|
||||
|
||||
const minChunkX = Math.floor((cameraX - halfViewW - margin) / chunkSize);
|
||||
const maxChunkX = Math.ceil((cameraX + halfViewW + margin) / chunkSize);
|
||||
const minChunkY = Math.floor((cameraY - halfViewH - margin) / chunkSize);
|
||||
const maxChunkY = Math.ceil((cameraY + halfViewH + margin) / chunkSize);
|
||||
|
||||
for (let cx = minChunkX; cx <= maxChunkX; cx++) {
|
||||
for (let cy = minChunkY; cy <= maxChunkY; cy++) {
|
||||
const coord = { x: cx, y: cy };
|
||||
const chunkInfo = chunkManager.getChunk(coord);
|
||||
|
||||
const worldMinX = cx * chunkSize;
|
||||
const worldMinY = cy * chunkSize;
|
||||
const screenPos = worldToScreen(worldMinX, worldMinY);
|
||||
const screenSize = chunkSize * cameraZoom;
|
||||
|
||||
const isAnchorChunk = anchorChunkCoords.some(
|
||||
ac => ac.x === cx && ac.y === cy
|
||||
);
|
||||
|
||||
const state = chunkInfo?.state ?? EChunkState.Unloaded;
|
||||
|
||||
if (state === EChunkState.Loaded) loadedCount++;
|
||||
if (state === EChunkState.Loading) loadingCount++;
|
||||
|
||||
visibleChunks.push({
|
||||
coord,
|
||||
state,
|
||||
screenX: screenPos.x,
|
||||
screenY: screenPos.y,
|
||||
screenWidth: screenSize,
|
||||
screenHeight: screenSize,
|
||||
isAnchorChunk
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setChunks(visibleChunks);
|
||||
setStats({
|
||||
loaded: loadedCount,
|
||||
loading: loadingCount,
|
||||
pendingLoad: chunkManager.pendingLoadCount,
|
||||
pendingUnload: chunkManager.pendingUnloadCount
|
||||
});
|
||||
};
|
||||
|
||||
updateChunks();
|
||||
const interval = setInterval(updateChunks, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [chunkManager, cameraX, cameraY, cameraZoom, viewWidth, viewHeight, chunkSize, worldToScreen, anchorChunkCoords]);
|
||||
|
||||
const getChunkClassName = (chunk: ChunkDisplayInfo): string => {
|
||||
const classes = ['chunk-grid-cell'];
|
||||
|
||||
switch (chunk.state) {
|
||||
case EChunkState.Loaded:
|
||||
classes.push('loaded');
|
||||
break;
|
||||
case EChunkState.Loading:
|
||||
classes.push('loading');
|
||||
break;
|
||||
case EChunkState.Unloading:
|
||||
classes.push('unloading');
|
||||
break;
|
||||
case EChunkState.Failed:
|
||||
classes.push('failed');
|
||||
break;
|
||||
}
|
||||
|
||||
if (chunk.isAnchorChunk) {
|
||||
classes.push('anchor-chunk');
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
};
|
||||
|
||||
const radiusIndicators = useMemo(() => {
|
||||
if (!bShowRadii || anchorPositions.length === 0) return null;
|
||||
|
||||
return anchorPositions.map((pos, idx) => {
|
||||
const loadRadiusWorld = (loadRadius + 0.5) * chunkSize;
|
||||
const unloadRadiusWorld = (unloadRadius + 0.5) * chunkSize;
|
||||
|
||||
const anchorChunkX = Math.floor(pos.x / chunkSize);
|
||||
const anchorChunkY = Math.floor(pos.y / chunkSize);
|
||||
const anchorChunkCenterX = (anchorChunkX + 0.5) * chunkSize;
|
||||
const anchorChunkCenterY = (anchorChunkY + 0.5) * chunkSize;
|
||||
|
||||
const loadScreenPos = worldToScreen(
|
||||
anchorChunkCenterX - loadRadiusWorld,
|
||||
anchorChunkCenterY - loadRadiusWorld
|
||||
);
|
||||
const loadScreenSize = loadRadiusWorld * 2 * cameraZoom;
|
||||
|
||||
const unloadScreenPos = worldToScreen(
|
||||
anchorChunkCenterX - unloadRadiusWorld,
|
||||
anchorChunkCenterY - unloadRadiusWorld
|
||||
);
|
||||
const unloadScreenSize = unloadRadiusWorld * 2 * cameraZoom;
|
||||
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<div
|
||||
className="unload-radius-indicator"
|
||||
style={{
|
||||
left: unloadScreenPos.x,
|
||||
top: unloadScreenPos.y,
|
||||
width: unloadScreenSize,
|
||||
height: unloadScreenSize
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="load-radius-indicator"
|
||||
style={{
|
||||
left: loadScreenPos.x,
|
||||
top: loadScreenPos.y,
|
||||
width: loadScreenSize,
|
||||
height: loadScreenSize
|
||||
}}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
}, [bShowRadii, anchorPositions, loadRadius, unloadRadius, chunkSize, worldToScreen, cameraZoom]);
|
||||
|
||||
const anchorMarkers = useMemo(() => {
|
||||
return anchorPositions.map((pos, idx) => {
|
||||
const screenPos = worldToScreen(pos.x, pos.y);
|
||||
return (
|
||||
<div
|
||||
key={`anchor-${idx}`}
|
||||
className="anchor-marker"
|
||||
style={{
|
||||
left: screenPos.x,
|
||||
top: screenPos.y
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}, [anchorPositions, worldToScreen]);
|
||||
|
||||
return (
|
||||
<div className="chunk-visualizer">
|
||||
<div className="chunk-visualizer-overlay">
|
||||
{chunks.map((chunk) => (
|
||||
<div
|
||||
key={`${chunk.coord.x},${chunk.coord.y}`}
|
||||
className={getChunkClassName(chunk)}
|
||||
style={{
|
||||
left: chunk.screenX,
|
||||
top: chunk.screenY,
|
||||
width: chunk.screenWidth,
|
||||
height: chunk.screenHeight
|
||||
}}
|
||||
>
|
||||
{bShowCoords && chunk.screenWidth > 40 && (
|
||||
<span className="chunk-coord-label">
|
||||
{chunk.coord.x},{chunk.coord.y}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{radiusIndicators}
|
||||
{anchorMarkers}
|
||||
</div>
|
||||
|
||||
{bShowStats && (
|
||||
<div className="chunk-stats-panel">
|
||||
<h4>Chunks</h4>
|
||||
<div className="chunk-stats-row">
|
||||
<span className="label">Loaded:</span>
|
||||
<span className="value loaded">{stats.loaded}</span>
|
||||
</div>
|
||||
<div className="chunk-stats-row">
|
||||
<span className="label">Loading:</span>
|
||||
<span className="value loading">{stats.loading}</span>
|
||||
</div>
|
||||
<div className="chunk-stats-row">
|
||||
<span className="label">Pending:</span>
|
||||
<span className="value pending">{stats.pendingLoad}</span>
|
||||
</div>
|
||||
<div className="chunk-stats-row">
|
||||
<span className="label">Unload Queue:</span>
|
||||
<span className="value">{stats.pendingUnload}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
20
packages/world-streaming-editor/src/index.ts
Normal file
20
packages/world-streaming-editor/src/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 世界流式加载编辑器模块入口
|
||||
* World Streaming Editor Module Entry
|
||||
*/
|
||||
|
||||
// Module
|
||||
export {
|
||||
WorldStreamingEditorModule,
|
||||
worldStreamingEditorModule,
|
||||
WorldStreamingPlugin,
|
||||
worldStreamingEditorModule as default
|
||||
} from './WorldStreamingEditorModule';
|
||||
|
||||
// Providers
|
||||
export { ChunkLoaderInspectorProvider } from './providers/ChunkLoaderInspectorProvider';
|
||||
export { StreamingAnchorInspectorProvider } from './providers/StreamingAnchorInspectorProvider';
|
||||
|
||||
// Components
|
||||
export { ChunkVisualizer } from './components/ChunkVisualizer';
|
||||
export type { ChunkVisualizerProps } from './components/ChunkVisualizer';
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* ChunkLoader Inspector Provider
|
||||
*
|
||||
* Custom inspector for ChunkLoaderComponent with streaming configuration.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Settings, Play, Pause, RefreshCw } from 'lucide-react';
|
||||
import type { IInspectorProvider, InspectorContext } from '@esengine/editor-core';
|
||||
import type { ChunkLoaderComponent } from '@esengine/world-streaming';
|
||||
|
||||
interface ChunkLoaderInspectorData {
|
||||
entityId: string;
|
||||
component: ChunkLoaderComponent;
|
||||
}
|
||||
|
||||
export class ChunkLoaderInspectorProvider implements IInspectorProvider<ChunkLoaderInspectorData> {
|
||||
readonly id = 'chunk-loader-inspector';
|
||||
readonly name = 'Chunk Loader Inspector';
|
||||
readonly priority = 100;
|
||||
|
||||
canHandle(target: unknown): target is ChunkLoaderInspectorData {
|
||||
if (typeof target !== 'object' || target === null) return false;
|
||||
const obj = target as Record<string, unknown>;
|
||||
return 'entityId' in obj && 'component' in obj &&
|
||||
obj.component !== null &&
|
||||
typeof obj.component === 'object' &&
|
||||
'chunkSize' in (obj.component as Record<string, unknown>) &&
|
||||
'loadRadius' in (obj.component as Record<string, unknown>);
|
||||
}
|
||||
|
||||
render(data: ChunkLoaderInspectorData, _context: InspectorContext): React.ReactElement {
|
||||
const { component } = data;
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">
|
||||
<Settings size={14} style={{ marginRight: '6px' }} />
|
||||
Streaming Configuration
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Chunk Size</label>
|
||||
<span>{component.chunkSize} units</span>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Load Radius</label>
|
||||
<span>{component.loadRadius} chunks</span>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Unload Radius</label>
|
||||
<span>{component.unloadRadius} chunks</span>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Max Loads/Frame</label>
|
||||
<span>{component.maxLoadsPerFrame}</span>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Unload Delay</label>
|
||||
<span>{component.unloadDelay}ms</span>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Prefetch</label>
|
||||
<span>{component.bEnablePrefetch ? 'Enabled' : 'Disabled'}</span>
|
||||
</div>
|
||||
|
||||
{component.bEnablePrefetch && (
|
||||
<div className="property-row">
|
||||
<label>Prefetch Radius</label>
|
||||
<span>{component.prefetchRadius} chunks</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* StreamingAnchor Inspector Provider
|
||||
*
|
||||
* Custom inspector for StreamingAnchorComponent.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Anchor, Navigation } from 'lucide-react';
|
||||
import type { IInspectorProvider, InspectorContext } from '@esengine/editor-core';
|
||||
import type { StreamingAnchorComponent } from '@esengine/world-streaming';
|
||||
|
||||
interface StreamingAnchorInspectorData {
|
||||
entityId: string;
|
||||
component: StreamingAnchorComponent;
|
||||
}
|
||||
|
||||
export class StreamingAnchorInspectorProvider implements IInspectorProvider<StreamingAnchorInspectorData> {
|
||||
readonly id = 'streaming-anchor-inspector';
|
||||
readonly name = 'Streaming Anchor Inspector';
|
||||
readonly priority = 100;
|
||||
|
||||
canHandle(target: unknown): target is StreamingAnchorInspectorData {
|
||||
if (typeof target !== 'object' || target === null) return false;
|
||||
const obj = target as Record<string, unknown>;
|
||||
return 'entityId' in obj && 'component' in obj &&
|
||||
obj.component !== null &&
|
||||
typeof obj.component === 'object' &&
|
||||
'weight' in (obj.component as Record<string, unknown>) &&
|
||||
'bEnablePrefetch' in (obj.component as Record<string, unknown>) &&
|
||||
'velocityX' in (obj.component as Record<string, unknown>);
|
||||
}
|
||||
|
||||
render(data: StreamingAnchorInspectorData, _context: InspectorContext): React.ReactElement {
|
||||
const { component } = data;
|
||||
|
||||
const velocity = Math.sqrt(
|
||||
component.velocityX * component.velocityX +
|
||||
component.velocityY * component.velocityY
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">
|
||||
<Anchor size={14} style={{ marginRight: '6px' }} />
|
||||
Streaming Anchor
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Weight</label>
|
||||
<span>{component.weight.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Prefetch</label>
|
||||
<span>{component.bEnablePrefetch ? 'Enabled' : 'Disabled'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">
|
||||
<Navigation size={14} style={{ marginRight: '6px' }} />
|
||||
Movement (Runtime)
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Velocity</label>
|
||||
<span>{velocity.toFixed(1)} u/s</span>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Direction</label>
|
||||
<span>
|
||||
({component.velocityX.toFixed(1)}, {component.velocityY.toFixed(1)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
139
packages/world-streaming-editor/src/styles/ChunkVisualizer.css
Normal file
139
packages/world-streaming-editor/src/styles/ChunkVisualizer.css
Normal file
@@ -0,0 +1,139 @@
|
||||
.chunk-visualizer {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.chunk-visualizer-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chunk-grid-cell {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgba(100, 149, 237, 0.3);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.chunk-grid-cell.loaded {
|
||||
background-color: rgba(100, 149, 237, 0.1);
|
||||
border-color: rgba(100, 149, 237, 0.5);
|
||||
}
|
||||
|
||||
.chunk-grid-cell.loading {
|
||||
background-color: rgba(255, 193, 7, 0.2);
|
||||
border-color: rgba(255, 193, 7, 0.6);
|
||||
animation: chunk-loading-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.chunk-grid-cell.unloading {
|
||||
background-color: rgba(255, 87, 34, 0.2);
|
||||
border-color: rgba(255, 87, 34, 0.5);
|
||||
}
|
||||
|
||||
.chunk-grid-cell.failed {
|
||||
background-color: rgba(244, 67, 54, 0.2);
|
||||
border-color: rgba(244, 67, 54, 0.6);
|
||||
}
|
||||
|
||||
.chunk-grid-cell.anchor-chunk {
|
||||
border-color: rgba(76, 175, 80, 0.8);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
@keyframes chunk-loading-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.chunk-coord-label {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-family: monospace;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.anchor-marker {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(76, 175, 80, 0.8);
|
||||
border: 2px solid #4caf50;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.anchor-marker::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.load-radius-indicator {
|
||||
position: absolute;
|
||||
border: 2px dashed rgba(76, 175, 80, 0.5);
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.unload-radius-indicator {
|
||||
position: absolute;
|
||||
border: 1px dashed rgba(255, 87, 34, 0.3);
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chunk-stats-panel {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(30, 30, 30, 0.9);
|
||||
border: 1px solid #3e3e42;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: #cccccc;
|
||||
pointer-events: auto;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.chunk-stats-panel h4 {
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
border-bottom: 1px solid #3e3e42;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.chunk-stats-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.chunk-stats-row .label {
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.chunk-stats-row .value {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.chunk-stats-row .value.loaded { color: #64b5f6; }
|
||||
.chunk-stats-row .value.loading { color: #ffc107; }
|
||||
.chunk-stats-row .value.pending { color: #ff9800; }
|
||||
23
packages/world-streaming-editor/tsconfig.build.json
Normal file
23
packages/world-streaming-editor/tsconfig.build.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"jsx": "react-jsx",
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
11
packages/world-streaming-editor/tsconfig.json
Normal file
11
packages/world-streaming-editor/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
7
packages/world-streaming-editor/tsup.config.ts
Normal file
7
packages/world-streaming-editor/tsup.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import { editorOnlyPreset } from '../build-config/src/presets/plugin-tsup';
|
||||
|
||||
export default defineConfig({
|
||||
...editorOnlyPreset(),
|
||||
tsconfig: 'tsconfig.build.json'
|
||||
});
|
||||
39
packages/world-streaming/package.json
Normal file
39
packages/world-streaming/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@esengine/world-streaming",
|
||||
"version": "1.0.0",
|
||||
"description": "World streaming and chunk management system for open world games",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@esengine/ecs-framework": "workspace:*",
|
||||
"@esengine/engine-core": "workspace:*"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"streaming",
|
||||
"chunk",
|
||||
"open-world"
|
||||
],
|
||||
"author": "ESEngine",
|
||||
"license": "MIT"
|
||||
}
|
||||
50
packages/world-streaming/src/WorldStreamingModule.ts
Normal file
50
packages/world-streaming/src/WorldStreamingModule.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { ComponentRegistry } 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: typeof ComponentRegistry): 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();
|
||||
103
packages/world-streaming/src/components/ChunkComponent.ts
Normal file
103
packages/world-streaming/src/components/ChunkComponent.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
|
||||
import type { IChunkCoord, IChunkBounds } from '../types';
|
||||
import { EChunkState } from '../types';
|
||||
|
||||
/**
|
||||
* 区块组件
|
||||
*
|
||||
* Attached to chunk root entities. Tracks chunk state and bounds.
|
||||
*
|
||||
* 区块组件挂载在区块根实体上,用于管理区块状态和边界信息。
|
||||
*/
|
||||
@ECSComponent('Chunk')
|
||||
@Serializable({ version: 1, typeId: 'Chunk' })
|
||||
export class ChunkComponent extends Component {
|
||||
@Serialize()
|
||||
@Property({ type: 'integer', label: 'Coord X', readOnly: true })
|
||||
private _coordX: number = 0;
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'integer', label: 'Coord Y', readOnly: true })
|
||||
private _coordY: number = 0;
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'string', label: 'State', readOnly: true })
|
||||
private _state: EChunkState = EChunkState.Unloaded;
|
||||
|
||||
@Serialize()
|
||||
private _minX: number = 0;
|
||||
|
||||
@Serialize()
|
||||
private _minY: number = 0;
|
||||
|
||||
@Serialize()
|
||||
private _maxX: number = 0;
|
||||
|
||||
@Serialize()
|
||||
private _maxY: number = 0;
|
||||
|
||||
private _lastAccessTime: number = 0;
|
||||
|
||||
get coord(): IChunkCoord {
|
||||
return { x: this._coordX, y: this._coordY };
|
||||
}
|
||||
|
||||
get bounds(): IChunkBounds {
|
||||
return {
|
||||
minX: this._minX,
|
||||
minY: this._minY,
|
||||
maxX: this._maxX,
|
||||
maxY: this._maxY
|
||||
};
|
||||
}
|
||||
|
||||
get state(): EChunkState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
get lastAccessTime(): number {
|
||||
return this._lastAccessTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化区块
|
||||
*
|
||||
* Initialize chunk with coordinates and bounds.
|
||||
*/
|
||||
initialize(coord: IChunkCoord, bounds: IChunkBounds): void {
|
||||
this._coordX = coord.x;
|
||||
this._coordY = coord.y;
|
||||
this._minX = bounds.minX;
|
||||
this._minY = bounds.minY;
|
||||
this._maxX = bounds.maxX;
|
||||
this._maxY = bounds.maxY;
|
||||
this._lastAccessTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置区块状态
|
||||
*
|
||||
* Set chunk state.
|
||||
*/
|
||||
setState(state: EChunkState): void {
|
||||
this._state = state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新访问时间
|
||||
*
|
||||
* Update last access time for LRU tracking.
|
||||
*/
|
||||
touch(): void {
|
||||
this._lastAccessTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查点是否在区块内
|
||||
*
|
||||
* Check if a point is within chunk bounds.
|
||||
*/
|
||||
containsPoint(x: number, y: number): boolean {
|
||||
return x >= this._minX && x < this._maxX && y >= this._minY && y < this._maxY;
|
||||
}
|
||||
}
|
||||
133
packages/world-streaming/src/components/ChunkLoaderComponent.ts
Normal file
133
packages/world-streaming/src/components/ChunkLoaderComponent.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
|
||||
import type { IChunkCoord, IStreamingConfig } from '../types';
|
||||
import { DEFAULT_STREAMING_CONFIG } from '../types';
|
||||
|
||||
/**
|
||||
* 区块加载器组件
|
||||
*
|
||||
* Singleton component that manages streaming configuration.
|
||||
* Attach to a manager entity in the scene.
|
||||
*
|
||||
* 单例组件,管理流式加载配置。挂载在场景管理实体上。
|
||||
*/
|
||||
@ECSComponent('ChunkLoader')
|
||||
@Serializable({ version: 1, typeId: 'ChunkLoader' })
|
||||
export class ChunkLoaderComponent extends Component {
|
||||
@Serialize()
|
||||
@Property({ type: 'integer', label: 'Chunk Size', min: 64, max: 4096 })
|
||||
private _chunkSize: number = DEFAULT_STREAMING_CONFIG.chunkSize;
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'integer', label: 'Load Radius', min: 1, max: 10 })
|
||||
private _loadRadius: number = DEFAULT_STREAMING_CONFIG.loadRadius;
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'integer', label: 'Unload Radius', min: 2, max: 20 })
|
||||
private _unloadRadius: number = DEFAULT_STREAMING_CONFIG.unloadRadius;
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'integer', label: 'Max Loads Per Frame', min: 1, max: 10 })
|
||||
private _maxLoadsPerFrame: number = DEFAULT_STREAMING_CONFIG.maxLoadsPerFrame;
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'integer', label: 'Max Unloads Per Frame', min: 1, max: 10 })
|
||||
private _maxUnloadsPerFrame: number = DEFAULT_STREAMING_CONFIG.maxUnloadsPerFrame;
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'integer', label: 'Unload Delay (ms)', min: 0, max: 30000 })
|
||||
private _unloadDelay: number = DEFAULT_STREAMING_CONFIG.unloadDelay;
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Enable Prefetch' })
|
||||
private _bEnablePrefetch: boolean = DEFAULT_STREAMING_CONFIG.bEnablePrefetch;
|
||||
|
||||
@Serialize()
|
||||
@Property({ type: 'integer', label: 'Prefetch Radius', min: 0, max: 5 })
|
||||
private _prefetchRadius: number = DEFAULT_STREAMING_CONFIG.prefetchRadius;
|
||||
|
||||
get chunkSize(): number {
|
||||
return this._chunkSize;
|
||||
}
|
||||
|
||||
get loadRadius(): number {
|
||||
return this._loadRadius;
|
||||
}
|
||||
|
||||
get unloadRadius(): number {
|
||||
return this._unloadRadius;
|
||||
}
|
||||
|
||||
get maxLoadsPerFrame(): number {
|
||||
return this._maxLoadsPerFrame;
|
||||
}
|
||||
|
||||
get maxUnloadsPerFrame(): number {
|
||||
return this._maxUnloadsPerFrame;
|
||||
}
|
||||
|
||||
get unloadDelay(): number {
|
||||
return this._unloadDelay;
|
||||
}
|
||||
|
||||
get bEnablePrefetch(): boolean {
|
||||
return this._bEnablePrefetch;
|
||||
}
|
||||
|
||||
get prefetchRadius(): number {
|
||||
return this._prefetchRadius;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用配置
|
||||
*
|
||||
* Apply streaming configuration.
|
||||
*/
|
||||
applyConfig(config: Partial<IStreamingConfig>): void {
|
||||
if (config.chunkSize !== undefined) this._chunkSize = config.chunkSize;
|
||||
if (config.loadRadius !== undefined) this._loadRadius = config.loadRadius;
|
||||
if (config.unloadRadius !== undefined) this._unloadRadius = config.unloadRadius;
|
||||
if (config.maxLoadsPerFrame !== undefined) this._maxLoadsPerFrame = config.maxLoadsPerFrame;
|
||||
if (config.maxUnloadsPerFrame !== undefined) this._maxUnloadsPerFrame = config.maxUnloadsPerFrame;
|
||||
if (config.unloadDelay !== undefined) this._unloadDelay = config.unloadDelay;
|
||||
if (config.bEnablePrefetch !== undefined) this._bEnablePrefetch = config.bEnablePrefetch;
|
||||
if (config.prefetchRadius !== undefined) this._prefetchRadius = config.prefetchRadius;
|
||||
}
|
||||
|
||||
/**
|
||||
* 世界坐标转区块坐标
|
||||
*
|
||||
* Convert world position to chunk coordinates.
|
||||
*/
|
||||
worldToChunk(worldX: number, worldY: number): IChunkCoord {
|
||||
return {
|
||||
x: Math.floor(worldX / this._chunkSize),
|
||||
y: Math.floor(worldY / this._chunkSize)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 区块坐标转世界坐标(返回区块中心)
|
||||
*
|
||||
* Convert chunk coordinates to world position (chunk center).
|
||||
*/
|
||||
chunkToWorld(coord: IChunkCoord): { x: number; y: number } {
|
||||
return {
|
||||
x: coord.x * this._chunkSize + this._chunkSize * 0.5,
|
||||
y: coord.y * this._chunkSize + this._chunkSize * 0.5
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取区块边界
|
||||
*
|
||||
* Get chunk world-space bounds.
|
||||
*/
|
||||
getChunkBounds(coord: IChunkCoord): { minX: number; minY: number; maxX: number; maxY: number } {
|
||||
return {
|
||||
minX: coord.x * this._chunkSize,
|
||||
minY: coord.y * this._chunkSize,
|
||||
maxX: (coord.x + 1) * this._chunkSize,
|
||||
maxY: (coord.y + 1) * this._chunkSize
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Component, ECSComponent, Serializable, Serialize, Property } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 流式锚点组件
|
||||
*
|
||||
* Marks an entity as a streaming anchor point.
|
||||
* Chunks are loaded/unloaded based on distance to anchors.
|
||||
*
|
||||
* 标记实体作为流式加载锚点。通常挂载在玩家或摄像机实体上,
|
||||
* 系统会根据锚点位置加载/卸载周围区块。
|
||||
*/
|
||||
@ECSComponent('StreamingAnchor')
|
||||
@Serializable({ version: 1, typeId: 'StreamingAnchor' })
|
||||
export class StreamingAnchorComponent extends Component {
|
||||
/**
|
||||
* 锚点权重
|
||||
*
|
||||
* Weight multiplier for this anchor's load radius.
|
||||
* Higher values mean larger load radius around this anchor.
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Weight', min: 0.1, max: 10 })
|
||||
weight: number = 1.0;
|
||||
|
||||
/**
|
||||
* 是否启用预加载
|
||||
*
|
||||
* Enable directional prefetching based on movement.
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Enable Prefetch' })
|
||||
bEnablePrefetch: boolean = true;
|
||||
|
||||
/**
|
||||
* 上一帧位置 X
|
||||
*
|
||||
* Previous frame X position for velocity calculation.
|
||||
*/
|
||||
previousX: number = 0;
|
||||
|
||||
/**
|
||||
* 上一帧位置 Y
|
||||
*
|
||||
* Previous frame Y position for velocity calculation.
|
||||
*/
|
||||
previousY: number = 0;
|
||||
|
||||
/**
|
||||
* 速度 X 分量
|
||||
*
|
||||
* X component of velocity (units per second).
|
||||
*/
|
||||
velocityX: number = 0;
|
||||
|
||||
/**
|
||||
* 速度 Y 分量
|
||||
*
|
||||
* Y component of velocity (units per second).
|
||||
*/
|
||||
velocityY: number = 0;
|
||||
}
|
||||
3
packages/world-streaming/src/components/index.ts
Normal file
3
packages/world-streaming/src/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ChunkComponent } from './ChunkComponent';
|
||||
export { StreamingAnchorComponent } from './StreamingAnchorComponent';
|
||||
export { ChunkLoaderComponent } from './ChunkLoaderComponent';
|
||||
53
packages/world-streaming/src/index.ts
Normal file
53
packages/world-streaming/src/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @esengine/world-streaming
|
||||
*
|
||||
* World streaming and chunk management system for open world games.
|
||||
*
|
||||
* 世界流式加载和区块管理系统,用于开放世界游戏。
|
||||
*/
|
||||
|
||||
// Types
|
||||
export {
|
||||
EChunkState,
|
||||
EChunkPriority,
|
||||
DEFAULT_STREAMING_CONFIG
|
||||
} from './types';
|
||||
|
||||
export type {
|
||||
IChunkCoord,
|
||||
IChunkBounds,
|
||||
IChunkData,
|
||||
ISerializedEntity,
|
||||
IChunkInfo,
|
||||
IChunkLoadRequest,
|
||||
IStreamingConfig
|
||||
} from './types';
|
||||
|
||||
// Components
|
||||
export {
|
||||
ChunkComponent,
|
||||
StreamingAnchorComponent,
|
||||
ChunkLoaderComponent
|
||||
} from './components';
|
||||
|
||||
// Systems
|
||||
export {
|
||||
ChunkStreamingSystem,
|
||||
ChunkCullingSystem
|
||||
} from './systems';
|
||||
|
||||
// Services
|
||||
export {
|
||||
SpatialHashGrid,
|
||||
ChunkSerializer,
|
||||
ChunkManager
|
||||
} from './services';
|
||||
|
||||
export type {
|
||||
IChunkSerializer,
|
||||
IChunkDataProvider,
|
||||
IChunkManagerEvents
|
||||
} from './services';
|
||||
|
||||
// Module
|
||||
export { WorldStreamingModule, worldStreamingModule } from './WorldStreamingModule';
|
||||
421
packages/world-streaming/src/services/ChunkManager.ts
Normal file
421
packages/world-streaming/src/services/ChunkManager.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
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';
|
||||
import type { IChunkSerializer } from './ChunkSerializer';
|
||||
import { ChunkSerializer } from './ChunkSerializer';
|
||||
import { ChunkComponent } from '../components/ChunkComponent';
|
||||
|
||||
/**
|
||||
* 区块数据提供者接口
|
||||
*
|
||||
* Interface for chunk data loading/saving.
|
||||
*/
|
||||
export interface IChunkDataProvider {
|
||||
loadChunkData(coord: IChunkCoord): Promise<IChunkData | null>;
|
||||
saveChunkData(data: IChunkData): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 区块管理器事件
|
||||
*
|
||||
* Events emitted by ChunkManager.
|
||||
*/
|
||||
export interface IChunkManagerEvents {
|
||||
onChunkLoaded?: (coord: IChunkCoord, entities: Entity[]) => void;
|
||||
onChunkUnloaded?: (coord: IChunkCoord) => void;
|
||||
onChunkLoadFailed?: (coord: IChunkCoord, error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 区块管理器
|
||||
*
|
||||
* Manages chunk lifecycle, loading queue, and spatial queries.
|
||||
*
|
||||
* 区块管理器负责区块生命周期、加载队列和空间查询。
|
||||
*/
|
||||
export class ChunkManager implements IService {
|
||||
private _chunkGrid: SpatialHashGrid<IChunkInfo>;
|
||||
private _loadQueue: IChunkLoadRequest[] = [];
|
||||
private _unloadQueue: Array<{ coord: IChunkCoord; scheduledTime: number }> = [];
|
||||
private _scene: IScene | null = null;
|
||||
private _dataProvider: IChunkDataProvider | null = null;
|
||||
private _serializer: IChunkSerializer;
|
||||
private _events: IChunkManagerEvents = {};
|
||||
private _chunkSize: number;
|
||||
|
||||
constructor(chunkSize: number = 512, serializer?: IChunkSerializer) {
|
||||
this._chunkSize = chunkSize;
|
||||
this._chunkGrid = new SpatialHashGrid<IChunkInfo>(chunkSize);
|
||||
this._serializer = serializer ?? new ChunkSerializer();
|
||||
}
|
||||
|
||||
get chunkSize(): number {
|
||||
return this._chunkSize;
|
||||
}
|
||||
|
||||
get loadedChunkCount(): number {
|
||||
return this._chunkGrid.size;
|
||||
}
|
||||
|
||||
get pendingLoadCount(): number {
|
||||
return this._loadQueue.length;
|
||||
}
|
||||
|
||||
get pendingUnloadCount(): number {
|
||||
return this._unloadQueue.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置场景
|
||||
*
|
||||
* Set the scene for entity creation.
|
||||
*/
|
||||
setScene(scene: IScene): void {
|
||||
this._scene = scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置数据提供者
|
||||
*
|
||||
* Set the chunk data provider.
|
||||
*/
|
||||
setDataProvider(provider: IChunkDataProvider): void {
|
||||
this._dataProvider = provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置事件回调
|
||||
*
|
||||
* Set event callbacks.
|
||||
*/
|
||||
setEvents(events: IChunkManagerEvents): void {
|
||||
this._events = events;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求加载区块
|
||||
*
|
||||
* Request a chunk to be loaded.
|
||||
*/
|
||||
requestLoad(coord: IChunkCoord, priority: EChunkPriority = EChunkPriority.Normal): void {
|
||||
const existing = this._chunkGrid.get(coord);
|
||||
if (existing && existing.state !== EChunkState.Unloaded && existing.state !== EChunkState.Failed) {
|
||||
if (existing.state === EChunkState.Loaded) {
|
||||
existing.lastAccessTime = Date.now();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const existingRequest = this._loadQueue.find(
|
||||
(r) => r.coord.x === coord.x && r.coord.y === coord.y
|
||||
);
|
||||
|
||||
if (existingRequest) {
|
||||
if (priority < existingRequest.priority) {
|
||||
existingRequest.priority = priority;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._loadQueue.push({
|
||||
coord,
|
||||
priority,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
this.sortLoadQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求卸载区块
|
||||
*
|
||||
* Request a chunk to be unloaded.
|
||||
*/
|
||||
requestUnload(coord: IChunkCoord, delay: number = 0): void {
|
||||
const chunk = this._chunkGrid.get(coord);
|
||||
if (!chunk || chunk.state !== EChunkState.Loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingRequest = this._unloadQueue.find(
|
||||
(r) => r.coord.x === coord.x && r.coord.y === coord.y
|
||||
);
|
||||
|
||||
if (!existingRequest) {
|
||||
this._unloadQueue.push({
|
||||
coord,
|
||||
scheduledTime: Date.now() + delay
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消卸载请求
|
||||
*
|
||||
* Cancel a pending unload request.
|
||||
*/
|
||||
cancelUnload(coord: IChunkCoord): void {
|
||||
const index = this._unloadQueue.findIndex(
|
||||
(r) => r.coord.x === coord.x && r.coord.y === coord.y
|
||||
);
|
||||
|
||||
if (index >= 0) {
|
||||
this._unloadQueue.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理加载队列
|
||||
*
|
||||
* Process pending load requests.
|
||||
*/
|
||||
async processLoads(maxCount: number): Promise<void> {
|
||||
let processed = 0;
|
||||
|
||||
while (processed < maxCount && this._loadQueue.length > 0) {
|
||||
const request = this._loadQueue.shift()!;
|
||||
await this.loadChunk(request.coord);
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理卸载队列
|
||||
*
|
||||
* Process pending unload requests.
|
||||
*/
|
||||
processUnloads(maxCount: number): void {
|
||||
const now = Date.now();
|
||||
let processed = 0;
|
||||
|
||||
const readyToUnload = this._unloadQueue.filter((r) => r.scheduledTime <= now);
|
||||
|
||||
for (const request of readyToUnload) {
|
||||
if (processed >= maxCount) break;
|
||||
|
||||
this.unloadChunk(request.coord);
|
||||
processed++;
|
||||
|
||||
const index = this._unloadQueue.indexOf(request);
|
||||
if (index >= 0) {
|
||||
this._unloadQueue.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载区块
|
||||
*
|
||||
* Load a single chunk.
|
||||
*/
|
||||
private async loadChunk(coord: IChunkCoord): Promise<void> {
|
||||
if (!this._scene) {
|
||||
console.warn('[ChunkManager] No scene set');
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = this.getChunkBounds(coord);
|
||||
const chunkInfo: IChunkInfo = {
|
||||
coord,
|
||||
state: EChunkState.Loading,
|
||||
priority: EChunkPriority.Normal,
|
||||
entities: [],
|
||||
bounds,
|
||||
lastAccessTime: Date.now(),
|
||||
distanceSq: 0
|
||||
};
|
||||
|
||||
this._chunkGrid.set(coord, chunkInfo);
|
||||
|
||||
try {
|
||||
let entities: Entity[];
|
||||
|
||||
if (this._dataProvider) {
|
||||
const data = await this._dataProvider.loadChunkData(coord);
|
||||
if (data) {
|
||||
entities = this._serializer.deserialize(data, this._scene);
|
||||
} else {
|
||||
entities = this.createEmptyChunk(coord, bounds);
|
||||
}
|
||||
} else {
|
||||
entities = this.createEmptyChunk(coord, bounds);
|
||||
}
|
||||
|
||||
chunkInfo.entities = entities;
|
||||
chunkInfo.state = EChunkState.Loaded;
|
||||
chunkInfo.lastAccessTime = Date.now();
|
||||
|
||||
this._events.onChunkLoaded?.(coord, entities);
|
||||
} catch (error) {
|
||||
chunkInfo.state = EChunkState.Failed;
|
||||
this._events.onChunkLoadFailed?.(coord, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载区块
|
||||
*
|
||||
* Unload a single chunk.
|
||||
*/
|
||||
private unloadChunk(coord: IChunkCoord): void {
|
||||
const chunk = this._chunkGrid.get(coord);
|
||||
if (!chunk) return;
|
||||
|
||||
chunk.state = EChunkState.Unloading;
|
||||
|
||||
for (const entity of chunk.entities) {
|
||||
entity.destroy();
|
||||
}
|
||||
|
||||
this._chunkGrid.delete(coord);
|
||||
this._events.onChunkUnloaded?.(coord);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空区块
|
||||
*
|
||||
* Create an empty chunk entity.
|
||||
*/
|
||||
private createEmptyChunk(coord: IChunkCoord, bounds: IChunkBounds): Entity[] {
|
||||
if (!this._scene) return [];
|
||||
|
||||
const chunkEntity = this._scene.createEntity(`Chunk_${coord.x}_${coord.y}`);
|
||||
const chunkComponent = chunkEntity.addComponent(new ChunkComponent());
|
||||
chunkComponent.initialize(coord, bounds);
|
||||
chunkComponent.setState(EChunkState.Loaded);
|
||||
|
||||
const transform = chunkEntity.getComponent(TransformComponent);
|
||||
if (transform) {
|
||||
transform.setPosition(bounds.minX, bounds.minY);
|
||||
}
|
||||
|
||||
return [chunkEntity];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取区块边界
|
||||
*
|
||||
* Get world-space bounds for a chunk.
|
||||
*/
|
||||
getChunkBounds(coord: IChunkCoord): IChunkBounds {
|
||||
return {
|
||||
minX: coord.x * this._chunkSize,
|
||||
minY: coord.y * this._chunkSize,
|
||||
maxX: (coord.x + 1) * this._chunkSize,
|
||||
maxY: (coord.y + 1) * this._chunkSize
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 世界坐标转区块坐标
|
||||
*
|
||||
* Convert world position to chunk coordinates.
|
||||
*/
|
||||
worldToChunk(worldX: number, worldY: number): IChunkCoord {
|
||||
return {
|
||||
x: Math.floor(worldX / this._chunkSize),
|
||||
y: Math.floor(worldY / this._chunkSize)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取区块信息
|
||||
*
|
||||
* Get chunk info by coordinates.
|
||||
*/
|
||||
getChunk(coord: IChunkCoord): IChunkInfo | undefined {
|
||||
return this._chunkGrid.get(coord);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查区块是否已加载
|
||||
*
|
||||
* Check if a chunk is loaded.
|
||||
*/
|
||||
isChunkLoaded(coord: IChunkCoord): boolean {
|
||||
const chunk = this._chunkGrid.get(coord);
|
||||
return chunk !== undefined && chunk.state === EChunkState.Loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取需要加载的区块坐标
|
||||
*
|
||||
* Get chunk coordinates that need to be loaded within radius.
|
||||
*/
|
||||
getMissingChunks(centerCoord: IChunkCoord, radius: number): IChunkCoord[] {
|
||||
const missing: IChunkCoord[] = [];
|
||||
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
const coord = { x: centerCoord.x + dx, y: centerCoord.y + dy };
|
||||
if (!this.isChunkLoaded(coord)) {
|
||||
missing.push(coord);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取超出范围的已加载区块
|
||||
*
|
||||
* Get loaded chunks outside the given radius.
|
||||
*/
|
||||
getChunksOutsideRadius(centerCoord: IChunkCoord, radius: number): IChunkCoord[] {
|
||||
const outside: IChunkCoord[] = [];
|
||||
|
||||
this._chunkGrid.forEach((_info, coord) => {
|
||||
const dx = Math.abs(coord.x - centerCoord.x);
|
||||
const dy = Math.abs(coord.y - centerCoord.y);
|
||||
|
||||
if (dx > radius || dy > radius) {
|
||||
outside.push(coord);
|
||||
}
|
||||
});
|
||||
|
||||
return outside;
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序加载队列
|
||||
*
|
||||
* Sort load queue by priority and timestamp.
|
||||
*/
|
||||
private sortLoadQueue(): void {
|
||||
this._loadQueue.sort((a, b) => {
|
||||
if (a.priority !== b.priority) {
|
||||
return a.priority - b.priority;
|
||||
}
|
||||
return a.timestamp - b.timestamp;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有区块
|
||||
*
|
||||
* Unload all chunks.
|
||||
*/
|
||||
clear(): void {
|
||||
this._chunkGrid.forEach((_info, coord) => {
|
||||
this.unloadChunk(coord);
|
||||
});
|
||||
|
||||
this._loadQueue = [];
|
||||
this._unloadQueue = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资源
|
||||
*
|
||||
* Dispose resources (IService interface).
|
||||
*/
|
||||
dispose(): void {
|
||||
this.clear();
|
||||
this._scene = null;
|
||||
this._dataProvider = null;
|
||||
this._events = {};
|
||||
}
|
||||
}
|
||||
145
packages/world-streaming/src/services/ChunkSerializer.ts
Normal file
145
packages/world-streaming/src/services/ChunkSerializer.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { Entity, IScene } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import type { IChunkCoord, IChunkData, ISerializedEntity, IChunkBounds } from '../types';
|
||||
|
||||
/**
|
||||
* 区块序列化器接口
|
||||
*
|
||||
* Interface for chunk serialization/deserialization.
|
||||
*/
|
||||
export interface IChunkSerializer {
|
||||
serialize(coord: IChunkCoord, entities: Entity[], bounds: IChunkBounds): IChunkData;
|
||||
deserialize(data: IChunkData, scene: IScene): Entity[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认区块序列化器
|
||||
*
|
||||
* Default chunk serializer implementation.
|
||||
* Override for custom serialization logic.
|
||||
*/
|
||||
export class ChunkSerializer implements IChunkSerializer {
|
||||
private static readonly DATA_VERSION = 1;
|
||||
|
||||
/**
|
||||
* 序列化区块
|
||||
*
|
||||
* Serialize entities within a chunk.
|
||||
*/
|
||||
serialize(coord: IChunkCoord, entities: Entity[], bounds: IChunkBounds): IChunkData {
|
||||
const serializedEntities: ISerializedEntity[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
if (!transform) continue;
|
||||
|
||||
const serialized: ISerializedEntity = {
|
||||
name: entity.name,
|
||||
localPosition: {
|
||||
x: transform.position.x - bounds.minX,
|
||||
y: transform.position.y - bounds.minY
|
||||
},
|
||||
components: this.serializeComponents(entity)
|
||||
};
|
||||
|
||||
serializedEntities.push(serialized);
|
||||
}
|
||||
|
||||
return {
|
||||
coord,
|
||||
entities: serializedEntities,
|
||||
version: ChunkSerializer.DATA_VERSION
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 反序列化区块
|
||||
*
|
||||
* Deserialize chunk data and create entities.
|
||||
*/
|
||||
deserialize(data: IChunkData, scene: IScene): Entity[] {
|
||||
const entities: Entity[] = [];
|
||||
const bounds = this.calculateBounds(data.coord);
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
this.deserializeComponents(entity, entityData.components);
|
||||
entities.push(entity);
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化实体组件
|
||||
*
|
||||
* Serialize entity components.
|
||||
*/
|
||||
protected serializeComponents(entity: Entity): Record<string, unknown> {
|
||||
const componentsData: Record<string, unknown> = {};
|
||||
|
||||
for (const component of entity.components) {
|
||||
const componentName = component.constructor.name;
|
||||
|
||||
if (this.shouldSerializeComponent(componentName)) {
|
||||
componentsData[componentName] = this.serializeComponent(component);
|
||||
}
|
||||
}
|
||||
|
||||
return componentsData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反序列化组件数据
|
||||
*
|
||||
* Deserialize component data to entity.
|
||||
*/
|
||||
protected deserializeComponents(_entity: Entity, _components: Record<string, unknown>): void {
|
||||
// Override in subclass to handle specific component types
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查组件是否需要序列化
|
||||
*
|
||||
* Check if component should be serialized.
|
||||
*/
|
||||
protected shouldSerializeComponent(componentName: string): boolean {
|
||||
const excludeList = ['TransformComponent', 'ChunkComponent', 'StreamingAnchorComponent'];
|
||||
return !excludeList.includes(componentName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化单个组件
|
||||
*
|
||||
* Serialize a single component.
|
||||
*/
|
||||
protected serializeComponent(component: unknown): unknown {
|
||||
if (typeof component === 'object' && component !== null && 'toJSON' in component) {
|
||||
return (component as { toJSON: () => unknown }).toJSON();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算区块边界
|
||||
*
|
||||
* Calculate chunk bounds from coordinates.
|
||||
*/
|
||||
private calculateBounds(coord: IChunkCoord, chunkSize: number = 512): IChunkBounds {
|
||||
return {
|
||||
minX: coord.x * chunkSize,
|
||||
minY: coord.y * chunkSize,
|
||||
maxX: (coord.x + 1) * chunkSize,
|
||||
maxY: (coord.y + 1) * chunkSize
|
||||
};
|
||||
}
|
||||
}
|
||||
173
packages/world-streaming/src/services/SpatialHashGrid.ts
Normal file
173
packages/world-streaming/src/services/SpatialHashGrid.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { IChunkCoord } from '../types';
|
||||
|
||||
/**
|
||||
* 空间哈希网格
|
||||
*
|
||||
* Spatial hash grid for fast chunk lookups.
|
||||
*
|
||||
* 用于快速查询指定位置或范围内的区块。
|
||||
*/
|
||||
export class SpatialHashGrid<T> {
|
||||
private _cells: Map<string, T> = new Map();
|
||||
private _cellSize: number;
|
||||
|
||||
constructor(cellSize: number) {
|
||||
this._cellSize = cellSize;
|
||||
}
|
||||
|
||||
get cellSize(): number {
|
||||
return this._cellSize;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this._cells.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成网格键
|
||||
*
|
||||
* Generate hash key from coordinates.
|
||||
*/
|
||||
private getKey(x: number, y: number): string {
|
||||
return `${x},${y}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置单元格数据
|
||||
*
|
||||
* Set data at grid coordinates.
|
||||
*/
|
||||
set(coord: IChunkCoord, value: T): void {
|
||||
this._cells.set(this.getKey(coord.x, coord.y), value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单元格数据
|
||||
*
|
||||
* Get data at grid coordinates.
|
||||
*/
|
||||
get(coord: IChunkCoord): T | undefined {
|
||||
return this._cells.get(this.getKey(coord.x, coord.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查单元格是否存在
|
||||
*
|
||||
* Check if data exists at coordinates.
|
||||
*/
|
||||
has(coord: IChunkCoord): boolean {
|
||||
return this._cells.has(this.getKey(coord.x, coord.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单元格
|
||||
*
|
||||
* Delete data at coordinates.
|
||||
*/
|
||||
delete(coord: IChunkCoord): boolean {
|
||||
return this._cells.delete(this.getKey(coord.x, coord.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空网格
|
||||
*
|
||||
* Clear all cells.
|
||||
*/
|
||||
clear(): void {
|
||||
this._cells.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 世界坐标转网格坐标
|
||||
*
|
||||
* Convert world position to grid coordinates.
|
||||
*/
|
||||
worldToGrid(worldX: number, worldY: number): IChunkCoord {
|
||||
return {
|
||||
x: Math.floor(worldX / this._cellSize),
|
||||
y: Math.floor(worldY / this._cellSize)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询范围内的所有单元格
|
||||
*
|
||||
* Query all cells within a range.
|
||||
*/
|
||||
queryRange(centerCoord: IChunkCoord, radius: number): T[] {
|
||||
const results: T[] = [];
|
||||
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
const value = this.get({ x: centerCoord.x + dx, y: centerCoord.y + dy });
|
||||
if (value !== undefined) {
|
||||
results.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取范围内需要加载的坐标
|
||||
*
|
||||
* Get coordinates within range that need loading.
|
||||
*/
|
||||
getMissingInRange(centerCoord: IChunkCoord, radius: number): IChunkCoord[] {
|
||||
const missing: IChunkCoord[] = [];
|
||||
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
const coord = { x: centerCoord.x + dx, y: centerCoord.y + dy };
|
||||
if (!this.has(coord)) {
|
||||
missing.push(coord);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取范围外的所有单元格
|
||||
*
|
||||
* Get all cells outside a given range.
|
||||
*/
|
||||
getOutsideRange(centerCoord: IChunkCoord, radius: number): Array<{ coord: IChunkCoord; value: T }> {
|
||||
const outside: Array<{ coord: IChunkCoord; value: T }> = [];
|
||||
|
||||
for (const [key, value] of this._cells) {
|
||||
const [x, y] = key.split(',').map(Number);
|
||||
const dx = Math.abs(x - centerCoord.x);
|
||||
const dy = Math.abs(y - centerCoord.y);
|
||||
|
||||
if (dx > radius || dy > radius) {
|
||||
outside.push({ coord: { x, y }, value });
|
||||
}
|
||||
}
|
||||
|
||||
return outside;
|
||||
}
|
||||
|
||||
/**
|
||||
* 遍历所有单元格
|
||||
*
|
||||
* Iterate over all cells.
|
||||
*/
|
||||
forEach(callback: (value: T, coord: IChunkCoord) => void): void {
|
||||
for (const [key, value] of this._cells) {
|
||||
const [x, y] = key.split(',').map(Number);
|
||||
callback(value, { x, y });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有值
|
||||
*
|
||||
* Get all values.
|
||||
*/
|
||||
values(): IterableIterator<T> {
|
||||
return this._cells.values();
|
||||
}
|
||||
}
|
||||
5
packages/world-streaming/src/services/index.ts
Normal file
5
packages/world-streaming/src/services/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { SpatialHashGrid } from './SpatialHashGrid';
|
||||
export { ChunkSerializer } from './ChunkSerializer';
|
||||
export type { IChunkSerializer } from './ChunkSerializer';
|
||||
export { ChunkManager } from './ChunkManager';
|
||||
export type { IChunkDataProvider, IChunkManagerEvents } from './ChunkManager';
|
||||
108
packages/world-streaming/src/systems/ChunkCullingSystem.ts
Normal file
108
packages/world-streaming/src/systems/ChunkCullingSystem.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { EntitySystem, Matcher, ECSSystem } from '@esengine/ecs-framework';
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import { ChunkComponent } from '../components/ChunkComponent';
|
||||
import { EChunkState } from '../types';
|
||||
|
||||
/**
|
||||
* 区块裁剪系统
|
||||
*
|
||||
* Handles visibility culling for chunk entities.
|
||||
*
|
||||
* 处理区块实体的可见性裁剪。
|
||||
*/
|
||||
@ECSSystem('ChunkCulling', { updateOrder: -40 })
|
||||
export class ChunkCullingSystem extends EntitySystem {
|
||||
private _viewMinX: number = 0;
|
||||
private _viewMinY: number = 0;
|
||||
private _viewMaxX: number = 1920;
|
||||
private _viewMaxY: number = 1080;
|
||||
private _padding: number = 100;
|
||||
|
||||
constructor() {
|
||||
super(Matcher.all(ChunkComponent));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置视口边界
|
||||
*
|
||||
* Set viewport bounds for culling.
|
||||
*/
|
||||
setViewBounds(minX: number, minY: number, maxX: number, maxY: number): void {
|
||||
this._viewMinX = minX;
|
||||
this._viewMinY = minY;
|
||||
this._viewMaxX = maxX;
|
||||
this._viewMaxY = maxY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置裁剪边距
|
||||
*
|
||||
* Set padding for culling bounds.
|
||||
*/
|
||||
setPadding(padding: number): void {
|
||||
this._padding = padding;
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
const cullMinX = this._viewMinX - this._padding;
|
||||
const cullMinY = this._viewMinY - this._padding;
|
||||
const cullMaxX = this._viewMaxX + this._padding;
|
||||
const cullMaxY = this._viewMaxY + this._padding;
|
||||
|
||||
for (const entity of entities) {
|
||||
const chunk = entity.getComponent(ChunkComponent);
|
||||
if (!chunk) continue;
|
||||
|
||||
if (chunk.state !== EChunkState.Loaded) continue;
|
||||
|
||||
const bounds = chunk.bounds;
|
||||
const isVisible = this.boundsIntersect(
|
||||
bounds.minX,
|
||||
bounds.minY,
|
||||
bounds.maxX,
|
||||
bounds.maxY,
|
||||
cullMinX,
|
||||
cullMinY,
|
||||
cullMaxX,
|
||||
cullMaxY
|
||||
);
|
||||
|
||||
entity.enabled = isVisible;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查边界是否相交
|
||||
*
|
||||
* Check if two axis-aligned bounds intersect.
|
||||
*/
|
||||
private boundsIntersect(
|
||||
aMinX: number,
|
||||
aMinY: number,
|
||||
aMaxX: number,
|
||||
aMaxY: number,
|
||||
bMinX: number,
|
||||
bMinY: number,
|
||||
bMaxX: number,
|
||||
bMaxY: number
|
||||
): boolean {
|
||||
return aMinX < bMaxX && aMaxX > bMinX && aMinY < bMaxY && aMaxY > bMinY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新视口(从相机)
|
||||
*
|
||||
* Update viewport from camera position and size.
|
||||
*/
|
||||
updateFromCamera(cameraX: number, cameraY: number, viewWidth: number, viewHeight: number): void {
|
||||
const halfWidth = viewWidth * 0.5;
|
||||
const halfHeight = viewHeight * 0.5;
|
||||
|
||||
this.setViewBounds(
|
||||
cameraX - halfWidth,
|
||||
cameraY - halfHeight,
|
||||
cameraX + halfWidth,
|
||||
cameraY + halfHeight
|
||||
);
|
||||
}
|
||||
}
|
||||
273
packages/world-streaming/src/systems/ChunkStreamingSystem.ts
Normal file
273
packages/world-streaming/src/systems/ChunkStreamingSystem.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
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';
|
||||
import { EChunkPriority } from '../types';
|
||||
import type { IChunkCoord } from '../types';
|
||||
|
||||
/**
|
||||
* 区块流式加载系统
|
||||
*
|
||||
* Manages chunk loading/unloading based on streaming anchors.
|
||||
*
|
||||
* 根据流式锚点位置管理区块的加载和卸载。
|
||||
*/
|
||||
@ECSSystem('ChunkStreaming', { updateOrder: -50 })
|
||||
export class ChunkStreamingSystem extends EntitySystem {
|
||||
private _chunkManager: ChunkManager | null = null;
|
||||
private _loaderEntity: Entity | null = null;
|
||||
private _lastAnchorChunks: Map<Entity, IChunkCoord> = new Map();
|
||||
|
||||
constructor() {
|
||||
super(Matcher.all(StreamingAnchorComponent, TransformComponent));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置区块管理器
|
||||
*
|
||||
* Set the chunk manager instance.
|
||||
*/
|
||||
setChunkManager(manager: ChunkManager): void {
|
||||
this._chunkManager = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取区块管理器
|
||||
*
|
||||
* Get the chunk manager instance.
|
||||
*/
|
||||
get chunkManager(): ChunkManager | null {
|
||||
return this._chunkManager;
|
||||
}
|
||||
|
||||
initialize(): void {
|
||||
super.initialize();
|
||||
|
||||
if (!this._chunkManager) {
|
||||
this._chunkManager = new ChunkManager();
|
||||
}
|
||||
|
||||
const scene = this.scene;
|
||||
if (scene) {
|
||||
this._chunkManager.setScene(scene);
|
||||
this.findLoaderEntity(scene);
|
||||
}
|
||||
}
|
||||
|
||||
protected process(entities: readonly Entity[]): void {
|
||||
if (!this._chunkManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = this.getLoaderComponent();
|
||||
if (!loader) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaTime = Time.deltaTime;
|
||||
|
||||
this.updateAnchors(entities, deltaTime);
|
||||
this.updateChunkRequests(entities, loader);
|
||||
|
||||
this._chunkManager.processLoads(loader.maxLoadsPerFrame);
|
||||
this._chunkManager.processUnloads(loader.maxUnloadsPerFrame);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新锚点速度
|
||||
*
|
||||
* Update anchor velocities.
|
||||
*/
|
||||
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 || !transform) continue;
|
||||
|
||||
const currentX = transform.position.x;
|
||||
const currentY = transform.position.y;
|
||||
|
||||
if (deltaTime > 0) {
|
||||
anchor.velocityX = (currentX - anchor.previousX) / deltaTime;
|
||||
anchor.velocityY = (currentY - anchor.previousY) / deltaTime;
|
||||
}
|
||||
|
||||
anchor.previousX = currentX;
|
||||
anchor.previousY = currentY;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新区块加载/卸载请求
|
||||
*
|
||||
* Update chunk load/unload requests based on anchor positions.
|
||||
*/
|
||||
private updateChunkRequests(entities: readonly Entity[], loader: ChunkLoaderComponent): void {
|
||||
if (!this._chunkManager) return;
|
||||
|
||||
const centerCoords: IChunkCoord[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
const transform = entity.getComponent(TransformComponent);
|
||||
if (!transform) continue;
|
||||
|
||||
const coord = loader.worldToChunk(transform.position.x, transform.position.y);
|
||||
centerCoords.push(coord);
|
||||
|
||||
const lastCoord = this._lastAnchorChunks.get(entity);
|
||||
const hasMovedChunk = !lastCoord || lastCoord.x !== coord.x || lastCoord.y !== coord.y;
|
||||
|
||||
if (hasMovedChunk) {
|
||||
this._lastAnchorChunks.set(entity, coord);
|
||||
}
|
||||
|
||||
this.requestChunksForAnchor(entity, coord, loader);
|
||||
}
|
||||
|
||||
this.requestUnloadsOutsideRange(centerCoords, loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求锚点周围的区块
|
||||
*
|
||||
* Request chunks around an anchor point.
|
||||
*/
|
||||
private requestChunksForAnchor(
|
||||
entity: Entity,
|
||||
centerCoord: IChunkCoord,
|
||||
loader: ChunkLoaderComponent
|
||||
): void {
|
||||
if (!this._chunkManager) return;
|
||||
|
||||
const anchor = entity.getComponent(StreamingAnchorComponent);
|
||||
if (!anchor) return;
|
||||
|
||||
const effectiveRadius = Math.ceil(loader.loadRadius * anchor.weight);
|
||||
|
||||
for (let dx = -effectiveRadius; dx <= effectiveRadius; dx++) {
|
||||
for (let dy = -effectiveRadius; dy <= effectiveRadius; dy++) {
|
||||
const coord = { x: centerCoord.x + dx, y: centerCoord.y + dy };
|
||||
const distSq = dx * dx + dy * dy;
|
||||
|
||||
let priority: EChunkPriority;
|
||||
if (distSq === 0) {
|
||||
priority = EChunkPriority.Immediate;
|
||||
} else if (distSq <= 1) {
|
||||
priority = EChunkPriority.High;
|
||||
} else if (distSq <= 4) {
|
||||
priority = EChunkPriority.Normal;
|
||||
} else {
|
||||
priority = EChunkPriority.Low;
|
||||
}
|
||||
|
||||
this._chunkManager.requestLoad(coord, priority);
|
||||
this._chunkManager.cancelUnload(coord);
|
||||
}
|
||||
}
|
||||
|
||||
if (loader.bEnablePrefetch && anchor.bEnablePrefetch) {
|
||||
this.requestPrefetchChunks(anchor, centerCoord, loader);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求预加载区块
|
||||
*
|
||||
* Request prefetch chunks based on movement direction.
|
||||
*/
|
||||
private requestPrefetchChunks(
|
||||
anchor: StreamingAnchorComponent,
|
||||
centerCoord: IChunkCoord,
|
||||
loader: ChunkLoaderComponent
|
||||
): void {
|
||||
if (!this._chunkManager) return;
|
||||
|
||||
const velocityMagnitude = Math.sqrt(
|
||||
anchor.velocityX * anchor.velocityX + anchor.velocityY * anchor.velocityY
|
||||
);
|
||||
|
||||
if (velocityMagnitude < 10) return;
|
||||
|
||||
const dirX = anchor.velocityX / velocityMagnitude;
|
||||
const dirY = anchor.velocityY / velocityMagnitude;
|
||||
|
||||
const chunkDirX = Math.round(dirX);
|
||||
const chunkDirY = Math.round(dirY);
|
||||
|
||||
if (chunkDirX === 0 && chunkDirY === 0) return;
|
||||
|
||||
for (let i = 1; i <= loader.prefetchRadius; i++) {
|
||||
const coord = {
|
||||
x: centerCoord.x + chunkDirX * (loader.loadRadius + i),
|
||||
y: centerCoord.y + chunkDirY * (loader.loadRadius + i)
|
||||
};
|
||||
|
||||
this._chunkManager.requestLoad(coord, EChunkPriority.Prefetch);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求卸载超出范围的区块
|
||||
*
|
||||
* Request unload for chunks outside all anchors' ranges.
|
||||
*/
|
||||
private requestUnloadsOutsideRange(
|
||||
centerCoords: IChunkCoord[],
|
||||
loader: ChunkLoaderComponent
|
||||
): void {
|
||||
if (!this._chunkManager || centerCoords.length === 0) return;
|
||||
|
||||
const grid = (this._chunkManager as any)._chunkGrid;
|
||||
if (!grid) return;
|
||||
|
||||
grid.forEach((_info: unknown, coord: IChunkCoord) => {
|
||||
let isInRange = false;
|
||||
|
||||
for (const center of centerCoords) {
|
||||
const dx = Math.abs(coord.x - center.x);
|
||||
const dy = Math.abs(coord.y - center.y);
|
||||
|
||||
if (dx <= loader.unloadRadius && dy <= loader.unloadRadius) {
|
||||
isInRange = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInRange) {
|
||||
this._chunkManager!.requestUnload(coord, loader.unloadDelay);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找 ChunkLoader 实体
|
||||
*
|
||||
* Find the chunk loader entity.
|
||||
*/
|
||||
private findLoaderEntity(scene: Scene): void {
|
||||
const result = scene.queryAll(ChunkLoaderComponent);
|
||||
if (result.entities.length > 0) {
|
||||
this._loaderEntity = result.entities[0] as Entity;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 ChunkLoaderComponent
|
||||
*
|
||||
* Get the chunk loader component.
|
||||
*/
|
||||
private getLoaderComponent(): ChunkLoaderComponent | null {
|
||||
if (this._loaderEntity && !this._loaderEntity.isDestroyed) {
|
||||
return this._loaderEntity.getComponent(ChunkLoaderComponent);
|
||||
}
|
||||
|
||||
const scene = this.scene;
|
||||
if (!scene) return null;
|
||||
|
||||
this.findLoaderEntity(scene);
|
||||
return this._loaderEntity?.getComponent(ChunkLoaderComponent) ?? null;
|
||||
}
|
||||
}
|
||||
2
packages/world-streaming/src/systems/index.ts
Normal file
2
packages/world-streaming/src/systems/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ChunkStreamingSystem } from './ChunkStreamingSystem';
|
||||
export { ChunkCullingSystem } from './ChunkCullingSystem';
|
||||
96
packages/world-streaming/src/types/ChunkData.ts
Normal file
96
packages/world-streaming/src/types/ChunkData.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import { EChunkState, EChunkPriority } from './ChunkState';
|
||||
|
||||
/**
|
||||
* 区块坐标
|
||||
*
|
||||
* Chunk grid coordinates in world space.
|
||||
*/
|
||||
export interface IChunkCoord {
|
||||
/** X 轴区块索引 | Chunk index on X axis */
|
||||
readonly x: number;
|
||||
/** Y 轴区块索引 | Chunk index on Y axis */
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 区块边界
|
||||
*
|
||||
* World-space bounds of a chunk.
|
||||
*/
|
||||
export interface IChunkBounds {
|
||||
/** 最小 X 坐标 | Minimum X coordinate */
|
||||
readonly minX: number;
|
||||
/** 最小 Y 坐标 | Minimum Y coordinate */
|
||||
readonly minY: number;
|
||||
/** 最大 X 坐标 | Maximum X coordinate */
|
||||
readonly maxX: number;
|
||||
/** 最大 Y 坐标 | Maximum Y coordinate */
|
||||
readonly maxY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 区块数据
|
||||
*
|
||||
* Serializable chunk data for storage and streaming.
|
||||
*/
|
||||
export interface IChunkData {
|
||||
/** 区块坐标 | Chunk coordinates */
|
||||
readonly coord: IChunkCoord;
|
||||
/** 实体数据列表 | Serialized entity data */
|
||||
readonly entities: ISerializedEntity[];
|
||||
/** 区块元数据 | Chunk metadata */
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
/** 数据版本 | Data version for migration */
|
||||
readonly version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化实体数据
|
||||
*
|
||||
* Serialized entity format for chunk storage.
|
||||
*/
|
||||
export interface ISerializedEntity {
|
||||
/** 实体名称 | Entity name */
|
||||
name: string;
|
||||
/** 组件数据 | Component data map */
|
||||
components: Record<string, unknown>;
|
||||
/** 相对于区块的局部位置 | Local position relative to chunk */
|
||||
localPosition: { x: number; y: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时区块信息
|
||||
*
|
||||
* Runtime chunk state and references.
|
||||
*/
|
||||
export interface IChunkInfo {
|
||||
/** 区块坐标 | Chunk coordinates */
|
||||
coord: IChunkCoord;
|
||||
/** 当前状态 | Current state */
|
||||
state: EChunkState;
|
||||
/** 加载优先级 | Loading priority */
|
||||
priority: EChunkPriority;
|
||||
/** 区块内实体列表 | Entities within this chunk */
|
||||
entities: Entity[];
|
||||
/** 世界空间边界 | World-space bounds */
|
||||
bounds: IChunkBounds;
|
||||
/** 上次访问时间戳 | Last access timestamp for LRU */
|
||||
lastAccessTime: number;
|
||||
/** 距锚点的距离平方 | Squared distance to nearest anchor */
|
||||
distanceSq: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 区块加载请求
|
||||
*
|
||||
* Request to load a chunk with priority.
|
||||
*/
|
||||
export interface IChunkLoadRequest {
|
||||
/** 区块坐标 | Chunk coordinates */
|
||||
coord: IChunkCoord;
|
||||
/** 加载优先级 | Loading priority */
|
||||
priority: EChunkPriority;
|
||||
/** 请求时间戳 | Request timestamp */
|
||||
timestamp: number;
|
||||
}
|
||||
35
packages/world-streaming/src/types/ChunkState.ts
Normal file
35
packages/world-streaming/src/types/ChunkState.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 区块状态枚举
|
||||
*
|
||||
* Chunk lifecycle states for streaming management.
|
||||
*/
|
||||
export const enum EChunkState {
|
||||
/** 未加载 | Not loaded */
|
||||
Unloaded = 'unloaded',
|
||||
/** 加载中 | Loading in progress */
|
||||
Loading = 'loading',
|
||||
/** 已加载 | Fully loaded and ready */
|
||||
Loaded = 'loaded',
|
||||
/** 卸载中 | Unloading in progress */
|
||||
Unloading = 'unloading',
|
||||
/** 加载失败 | Failed to load */
|
||||
Failed = 'failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* 区块优先级
|
||||
*
|
||||
* Priority levels for chunk loading queue.
|
||||
*/
|
||||
export const enum EChunkPriority {
|
||||
/** 立即加载 | Immediate loading required */
|
||||
Immediate = 0,
|
||||
/** 高优先级 | High priority */
|
||||
High = 1,
|
||||
/** 普通优先级 | Normal priority */
|
||||
Normal = 2,
|
||||
/** 低优先级 | Low priority */
|
||||
Low = 3,
|
||||
/** 预加载 | Prefetch for future use */
|
||||
Prefetch = 4
|
||||
}
|
||||
76
packages/world-streaming/src/types/StreamingConfig.ts
Normal file
76
packages/world-streaming/src/types/StreamingConfig.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 世界流式加载配置
|
||||
*
|
||||
* Configuration for world streaming behavior.
|
||||
*/
|
||||
export interface IStreamingConfig {
|
||||
/** 区块大小(世界单位)| Chunk size in world units */
|
||||
chunkSize: number;
|
||||
|
||||
/**
|
||||
* 加载半径(区块数)
|
||||
*
|
||||
* How many chunks to load around each anchor.
|
||||
*/
|
||||
loadRadius: number;
|
||||
|
||||
/**
|
||||
* 卸载半径(区块数)
|
||||
*
|
||||
* Chunks beyond this radius will be unloaded.
|
||||
* Should be greater than loadRadius to prevent thrashing.
|
||||
*/
|
||||
unloadRadius: number;
|
||||
|
||||
/**
|
||||
* 每帧最大加载数
|
||||
*
|
||||
* Maximum chunks to load per frame to avoid stutter.
|
||||
*/
|
||||
maxLoadsPerFrame: number;
|
||||
|
||||
/**
|
||||
* 每帧最大卸载数
|
||||
*
|
||||
* Maximum chunks to unload per frame.
|
||||
*/
|
||||
maxUnloadsPerFrame: number;
|
||||
|
||||
/**
|
||||
* 最小卸载延迟(毫秒)
|
||||
*
|
||||
* Minimum time a chunk must be out of range before unloading.
|
||||
* Prevents rapid load/unload cycles.
|
||||
*/
|
||||
unloadDelay: number;
|
||||
|
||||
/**
|
||||
* 是否启用预加载
|
||||
*
|
||||
* Enable prefetching chunks in movement direction.
|
||||
*/
|
||||
bEnablePrefetch: boolean;
|
||||
|
||||
/**
|
||||
* 预加载半径(区块数)
|
||||
*
|
||||
* Additional chunks to prefetch ahead of movement.
|
||||
*/
|
||||
prefetchRadius: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认流式加载配置
|
||||
*
|
||||
* Default streaming configuration values.
|
||||
*/
|
||||
export const DEFAULT_STREAMING_CONFIG: Readonly<IStreamingConfig> = {
|
||||
chunkSize: 512,
|
||||
loadRadius: 2,
|
||||
unloadRadius: 4,
|
||||
maxLoadsPerFrame: 2,
|
||||
maxUnloadsPerFrame: 1,
|
||||
unloadDelay: 3000,
|
||||
bEnablePrefetch: true,
|
||||
prefetchRadius: 1
|
||||
};
|
||||
11
packages/world-streaming/src/types/index.ts
Normal file
11
packages/world-streaming/src/types/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { EChunkState, EChunkPriority } from './ChunkState';
|
||||
export type {
|
||||
IChunkCoord,
|
||||
IChunkBounds,
|
||||
IChunkData,
|
||||
ISerializedEntity,
|
||||
IChunkInfo,
|
||||
IChunkLoadRequest
|
||||
} from './ChunkData';
|
||||
export type { IStreamingConfig } from './StreamingConfig';
|
||||
export { DEFAULT_STREAMING_CONFIG } from './StreamingConfig';
|
||||
28
packages/world-streaming/tsconfig.json
Normal file
28
packages/world-streaming/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": false,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
10
packages/world-streaming/tsup.config.ts
Normal file
10
packages/world-streaming/tsup.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: ['@esengine/ecs-framework', '@esengine/engine-core']
|
||||
});
|
||||
56
pnpm-lock.yaml
generated
56
pnpm-lock.yaml
generated
@@ -1559,6 +1559,62 @@ importers:
|
||||
specifier: ^5.3.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/world-streaming:
|
||||
dependencies:
|
||||
'@esengine/ecs-framework':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
'@esengine/engine-core':
|
||||
specifier: workspace:*
|
||||
version: link:../engine-core
|
||||
devDependencies:
|
||||
tsup:
|
||||
specifier: ^8.0.1
|
||||
version: 8.5.1(@microsoft/api-extractor@7.55.1(@types/node@20.19.25))(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1)
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/world-streaming-editor:
|
||||
dependencies:
|
||||
'@esengine/world-streaming':
|
||||
specifier: workspace:*
|
||||
version: link:../world-streaming
|
||||
devDependencies:
|
||||
'@esengine/build-config':
|
||||
specifier: workspace:*
|
||||
version: link:../build-config
|
||||
'@esengine/ecs-framework':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
'@esengine/editor-core':
|
||||
specifier: workspace:*
|
||||
version: link:../editor-core
|
||||
'@esengine/engine-core':
|
||||
specifier: workspace:*
|
||||
version: link:../engine-core
|
||||
'@types/react':
|
||||
specifier: ^18.3.12
|
||||
version: 18.3.27
|
||||
lucide-react:
|
||||
specifier: ^0.545.0
|
||||
version: 0.545.0(react@18.3.1)
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
rimraf:
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.10
|
||||
tsup:
|
||||
specifier: ^8.0.0
|
||||
version: 8.5.1(@microsoft/api-extractor@7.55.1(@types/node@20.19.25))(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1)
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
version: 5.9.3
|
||||
zustand:
|
||||
specifier: ^5.0.8
|
||||
version: 5.0.8(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
||||
|
||||
packages:
|
||||
|
||||
'@algolia/abtesting@1.10.0':
|
||||
|
||||
Reference in New Issue
Block a user