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'
|
||||
});
|
||||
Reference in New Issue
Block a user