core库demo更新
This commit is contained in:
99
examples/core-demos/src/demos/DemoBase.ts
Normal file
99
examples/core-demos/src/demos/DemoBase.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Scene, Core } from '@esengine/ecs-framework';
|
||||
|
||||
export interface DemoInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export abstract class DemoBase {
|
||||
protected scene: Scene;
|
||||
protected canvas: HTMLCanvasElement;
|
||||
protected ctx: CanvasRenderingContext2D;
|
||||
protected controlPanel: HTMLElement;
|
||||
protected isRunning: boolean = false;
|
||||
protected animationFrameId: number | null = null;
|
||||
protected lastTime: number = 0;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement, controlPanel: HTMLElement) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d')!;
|
||||
this.controlPanel = controlPanel;
|
||||
this.scene = new Scene({ name: this.getInfo().name });
|
||||
|
||||
// 设置canvas大小
|
||||
this.resizeCanvas();
|
||||
window.addEventListener('resize', () => this.resizeCanvas());
|
||||
}
|
||||
|
||||
abstract getInfo(): DemoInfo;
|
||||
abstract setup(): void;
|
||||
abstract createControls(): void;
|
||||
|
||||
protected resizeCanvas() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
this.canvas.width = rect.width;
|
||||
this.canvas.height = rect.height;
|
||||
}
|
||||
|
||||
public start() {
|
||||
if (this.isRunning) return;
|
||||
this.isRunning = true;
|
||||
this.lastTime = performance.now();
|
||||
|
||||
// 设置当前场景到Core
|
||||
Core.setScene(this.scene);
|
||||
|
||||
this.scene.begin();
|
||||
this.loop();
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.isRunning = false;
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.stop();
|
||||
this.scene.end();
|
||||
}
|
||||
|
||||
protected loop = () => {
|
||||
if (!this.isRunning) return;
|
||||
|
||||
// 计算deltaTime
|
||||
const currentTime = performance.now();
|
||||
const deltaTime = (currentTime - this.lastTime) / 1000; // 转换为秒
|
||||
this.lastTime = currentTime;
|
||||
|
||||
// 更新ECS框架
|
||||
Core.update(deltaTime);
|
||||
|
||||
// 渲染
|
||||
this.render();
|
||||
|
||||
// 继续循环
|
||||
this.animationFrameId = requestAnimationFrame(this.loop);
|
||||
}
|
||||
|
||||
protected abstract render(): void;
|
||||
|
||||
protected showToast(message: string, icon: string = '✅') {
|
||||
const toast = document.getElementById('toast')!;
|
||||
const toastMessage = document.getElementById('toastMessage')!;
|
||||
const toastIcon = toast.querySelector('.toast-icon')!;
|
||||
|
||||
toastIcon.textContent = icon;
|
||||
toastMessage.textContent = message;
|
||||
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
387
examples/core-demos/src/demos/SerializationDemo.ts
Normal file
387
examples/core-demos/src/demos/SerializationDemo.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { DemoBase, DemoInfo } from './DemoBase';
|
||||
import {
|
||||
Component,
|
||||
ECSComponent,
|
||||
Entity,
|
||||
EntitySystem,
|
||||
Serializable,
|
||||
Serialize,
|
||||
SerializeAsMap
|
||||
} from '@esengine/ecs-framework';
|
||||
|
||||
// ===== 组件定义 =====
|
||||
@ECSComponent('SerDemo_Position')
|
||||
@Serializable({ version: 1, typeId: 'SerDemo_Position' })
|
||||
class PositionComponent extends Component {
|
||||
@Serialize() x: number = 0;
|
||||
@Serialize() y: number = 0;
|
||||
constructor(x: number = 0, y: number = 0) {
|
||||
super();
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
}
|
||||
|
||||
@ECSComponent('SerDemo_Velocity')
|
||||
@Serializable({ version: 1, typeId: 'SerDemo_Velocity' })
|
||||
class VelocityComponent extends Component {
|
||||
@Serialize() vx: number = 0;
|
||||
@Serialize() vy: number = 0;
|
||||
constructor(vx: number = 0, vy: number = 0) {
|
||||
super();
|
||||
this.vx = vx;
|
||||
this.vy = vy;
|
||||
}
|
||||
}
|
||||
|
||||
@ECSComponent('SerDemo_Renderable')
|
||||
@Serializable({ version: 1, typeId: 'SerDemo_Renderable' })
|
||||
class RenderableComponent extends Component {
|
||||
@Serialize() color: string = '#ffffff';
|
||||
@Serialize() radius: number = 10;
|
||||
constructor(color: string = '#ffffff', radius: number = 10) {
|
||||
super();
|
||||
this.color = color;
|
||||
this.radius = radius;
|
||||
}
|
||||
}
|
||||
|
||||
@ECSComponent('SerDemo_Player')
|
||||
@Serializable({ version: 1, typeId: 'SerDemo_Player' })
|
||||
class PlayerComponent extends Component {
|
||||
@Serialize() name: string = 'Player';
|
||||
@Serialize() level: number = 1;
|
||||
@Serialize() health: number = 100;
|
||||
@SerializeAsMap() inventory: Map<string, number> = new Map();
|
||||
constructor(name: string = 'Player') {
|
||||
super();
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 系统定义 =====
|
||||
class MovementSystem extends EntitySystem {
|
||||
update() {
|
||||
const entities = this.scene.entities.buffer;
|
||||
for (const entity of entities) {
|
||||
const pos = entity.getComponent(PositionComponent);
|
||||
const vel = entity.getComponent(VelocityComponent);
|
||||
if (pos && vel) {
|
||||
pos.x += vel.vx;
|
||||
pos.y += vel.vy;
|
||||
|
||||
// 边界反弹
|
||||
if (pos.x < 0 || pos.x > 1200) vel.vx *= -1;
|
||||
if (pos.y < 0 || pos.y > 600) vel.vy *= -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RenderSystem extends EntitySystem {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
super();
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d')!;
|
||||
}
|
||||
|
||||
update() {
|
||||
// 清空画布
|
||||
this.ctx.fillStyle = '#0a0a15';
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// 渲染所有实体
|
||||
const entities = this.scene.entities.buffer;
|
||||
for (const entity of entities) {
|
||||
const pos = entity.getComponent(PositionComponent);
|
||||
const render = entity.getComponent(RenderableComponent);
|
||||
if (pos && render) {
|
||||
this.ctx.fillStyle = render.color;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(pos.x, pos.y, render.radius, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
|
||||
// 如果是玩家,显示名字
|
||||
const player = entity.getComponent(PlayerComponent);
|
||||
if (player) {
|
||||
this.ctx.fillStyle = 'white';
|
||||
this.ctx.font = '12px Arial';
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.fillText(player.name, pos.x, pos.y - render.radius - 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SerializationDemo extends DemoBase {
|
||||
private renderSystem!: RenderSystem;
|
||||
private jsonData: string = '';
|
||||
private binaryData: Buffer | null = null;
|
||||
|
||||
getInfo(): DemoInfo {
|
||||
return {
|
||||
id: 'serialization',
|
||||
name: '场景序列化',
|
||||
description: '演示场景的序列化和反序列化功能,支持JSON和二进制格式',
|
||||
category: '核心功能',
|
||||
icon: '💾'
|
||||
};
|
||||
}
|
||||
|
||||
setup() {
|
||||
// @ECSComponent装饰器会自动注册组件到ComponentRegistry
|
||||
// ComponentRegistry会被序列化系统自动使用,无需手动注册
|
||||
|
||||
// 添加系统
|
||||
this.renderSystem = new RenderSystem(this.canvas);
|
||||
this.scene.addEntityProcessor(new MovementSystem());
|
||||
this.scene.addEntityProcessor(this.renderSystem);
|
||||
|
||||
// 创建初始实体
|
||||
this.createInitialEntities();
|
||||
|
||||
// 创建控制面板
|
||||
this.createControls();
|
||||
}
|
||||
|
||||
private createInitialEntities() {
|
||||
// 创建玩家
|
||||
const player = this.scene.createEntity('Player');
|
||||
player.addComponent(new PositionComponent(600, 300));
|
||||
player.addComponent(new VelocityComponent(2, 1.5));
|
||||
player.addComponent(new RenderableComponent('#4a9eff', 15));
|
||||
const playerComp = new PlayerComponent('Hero');
|
||||
playerComp.level = 5;
|
||||
playerComp.health = 100;
|
||||
playerComp.inventory.set('sword', 1);
|
||||
playerComp.inventory.set('potion', 5);
|
||||
player.addComponent(playerComp);
|
||||
|
||||
// 创建一些随机实体
|
||||
for (let i = 0; i < 5; i++) {
|
||||
this.createRandomEntity();
|
||||
}
|
||||
|
||||
// 设置场景数据
|
||||
this.scene.sceneData.set('weather', 'sunny');
|
||||
this.scene.sceneData.set('gameTime', 12.5);
|
||||
this.scene.sceneData.set('difficulty', 'normal');
|
||||
}
|
||||
|
||||
private createRandomEntity() {
|
||||
const entity = this.scene.createEntity(`Entity_${Date.now()}`);
|
||||
entity.addComponent(new PositionComponent(
|
||||
Math.random() * this.canvas.width,
|
||||
Math.random() * this.canvas.height
|
||||
));
|
||||
entity.addComponent(new VelocityComponent(
|
||||
(Math.random() - 0.5) * 3,
|
||||
(Math.random() - 0.5) * 3
|
||||
));
|
||||
const colors = ['#ff6b6b', '#4ecdc4', '#ffe66d', '#a8dadc', '#f1faee'];
|
||||
entity.addComponent(new RenderableComponent(
|
||||
colors[Math.floor(Math.random() * colors.length)],
|
||||
5 + Math.random() * 10
|
||||
));
|
||||
}
|
||||
|
||||
createControls() {
|
||||
this.controlPanel.innerHTML = `
|
||||
<div class="control-section">
|
||||
<h4>实体控制</h4>
|
||||
<div class="button-group">
|
||||
<button id="addEntity" class="secondary">添加随机实体</button>
|
||||
<button id="clearEntities" class="danger">清空所有实体</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h4>序列化操作</h4>
|
||||
<div class="button-group">
|
||||
<button id="serializeJSON">序列化为JSON</button>
|
||||
<button id="serializeBinary" class="success">序列化为二进制</button>
|
||||
<button id="deserialize" class="secondary">反序列化恢复</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h4>本地存储</h4>
|
||||
<div class="button-group">
|
||||
<button id="saveLocal" class="success">保存到LocalStorage</button>
|
||||
<button id="loadLocal" class="secondary">从LocalStorage加载</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h4>场景数据</h4>
|
||||
<div class="input-group">
|
||||
<label>天气</label>
|
||||
<input type="text" id="weather" value="sunny" placeholder="sunny/rainy/snowy">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>游戏时间</label>
|
||||
<input type="number" id="gameTime" value="12.5" step="0.1" min="0" max="24">
|
||||
</div>
|
||||
<button id="updateSceneData" class="secondary">更新场景数据</button>
|
||||
</div>
|
||||
|
||||
<div class="stats-panel">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">实体数量</div>
|
||||
<div class="stat-value" id="entityCount">0</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">JSON大小</div>
|
||||
<div class="stat-value" id="jsonSize">0B</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">二进制大小</div>
|
||||
<div class="stat-value" id="binarySize">0B</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">压缩率</div>
|
||||
<div class="stat-value" id="compressionRatio">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h4>序列化数据预览</h4>
|
||||
<div style="max-height: 200px; overflow-y: auto; background: rgba(0,0,0,0.3); padding: 10px; border-radius: 6px; font-family: monospace; font-size: 11px; color: #8892b0; word-break: break-all;" id="dataPreview">
|
||||
点击序列化按钮查看数据...
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 绑定事件
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
private bindEvents() {
|
||||
document.getElementById('addEntity')!.addEventListener('click', () => {
|
||||
this.createRandomEntity();
|
||||
this.updateStats();
|
||||
this.showToast('添加了一个随机实体');
|
||||
});
|
||||
|
||||
document.getElementById('clearEntities')!.addEventListener('click', () => {
|
||||
this.scene.destroyAllEntities();
|
||||
this.createInitialEntities();
|
||||
this.updateStats();
|
||||
this.showToast('场景已重置');
|
||||
});
|
||||
|
||||
document.getElementById('serializeJSON')!.addEventListener('click', () => {
|
||||
this.jsonData = this.scene.serialize({ format: 'json', pretty: true }) as string;
|
||||
this.updateDataPreview(this.jsonData, 'json');
|
||||
this.updateStats();
|
||||
this.showToast('已序列化为JSON格式');
|
||||
});
|
||||
|
||||
document.getElementById('serializeBinary')!.addEventListener('click', () => {
|
||||
this.binaryData = this.scene.serialize({ format: 'binary' }) as Buffer;
|
||||
const base64 = this.binaryData.toString('base64');
|
||||
this.updateDataPreview(`Binary Data (Base64):\n${base64.substring(0, 500)}...`, 'binary');
|
||||
this.updateStats();
|
||||
this.showToast('已序列化为二进制格式', '🔐');
|
||||
});
|
||||
|
||||
document.getElementById('deserialize')!.addEventListener('click', () => {
|
||||
const data = this.binaryData || this.jsonData;
|
||||
if (!data) {
|
||||
this.showToast('请先执行序列化操作', '⚠️');
|
||||
return;
|
||||
}
|
||||
|
||||
this.scene.deserialize(data, {
|
||||
strategy: 'replace'
|
||||
// componentRegistry会自动从ComponentRegistry获取,无需手动传入
|
||||
});
|
||||
|
||||
this.updateStats();
|
||||
this.showToast('场景已恢复');
|
||||
});
|
||||
|
||||
document.getElementById('saveLocal')!.addEventListener('click', () => {
|
||||
const jsonData = this.scene.serialize({ format: 'json' }) as string;
|
||||
localStorage.setItem('ecs_demo_scene', jsonData);
|
||||
this.showToast('已保存到LocalStorage', '💾');
|
||||
});
|
||||
|
||||
document.getElementById('loadLocal')!.addEventListener('click', () => {
|
||||
const data = localStorage.getItem('ecs_demo_scene');
|
||||
if (!data) {
|
||||
this.showToast('LocalStorage中没有保存的场景', '⚠️');
|
||||
return;
|
||||
}
|
||||
|
||||
this.scene.deserialize(data, {
|
||||
strategy: 'replace'
|
||||
// componentRegistry会自动从ComponentRegistry获取,无需手动传入
|
||||
});
|
||||
|
||||
this.updateStats();
|
||||
this.showToast('已从LocalStorage加载', '📂');
|
||||
});
|
||||
|
||||
document.getElementById('updateSceneData')!.addEventListener('click', () => {
|
||||
const weather = (document.getElementById('weather') as HTMLInputElement).value;
|
||||
const gameTime = parseFloat((document.getElementById('gameTime') as HTMLInputElement).value);
|
||||
|
||||
this.scene.sceneData.set('weather', weather);
|
||||
this.scene.sceneData.set('gameTime', gameTime);
|
||||
|
||||
this.showToast('场景数据已更新');
|
||||
});
|
||||
|
||||
// 初始更新统计
|
||||
this.updateStats();
|
||||
}
|
||||
|
||||
private updateDataPreview(data: string, format: string) {
|
||||
const preview = document.getElementById('dataPreview')!;
|
||||
if (format === 'json') {
|
||||
const truncated = data.length > 1000 ? data.substring(0, 1000) + '\n...(truncated)' : data;
|
||||
preview.textContent = truncated;
|
||||
} else {
|
||||
preview.textContent = data;
|
||||
}
|
||||
}
|
||||
|
||||
private updateStats() {
|
||||
const entityCount = this.scene.entities.count;
|
||||
document.getElementById('entityCount')!.textContent = entityCount.toString();
|
||||
|
||||
// 计算JSON大小
|
||||
if (this.jsonData) {
|
||||
const jsonSize = new Blob([this.jsonData]).size;
|
||||
document.getElementById('jsonSize')!.textContent = this.formatBytes(jsonSize);
|
||||
}
|
||||
|
||||
// 计算二进制大小
|
||||
if (this.binaryData) {
|
||||
const binarySize = this.binaryData.length;
|
||||
document.getElementById('binarySize')!.textContent = this.formatBytes(binarySize);
|
||||
|
||||
// 计算压缩率
|
||||
if (this.jsonData) {
|
||||
const jsonSize = new Blob([this.jsonData]).size;
|
||||
const ratio = ((1 - binarySize / jsonSize) * 100).toFixed(1);
|
||||
document.getElementById('compressionRatio')!.textContent = `${ratio}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
// RenderSystem会处理渲染
|
||||
}
|
||||
}
|
||||
835
examples/core-demos/src/demos/WorkerSystemDemo.ts
Normal file
835
examples/core-demos/src/demos/WorkerSystemDemo.ts
Normal file
@@ -0,0 +1,835 @@
|
||||
import { DemoBase, DemoInfo } from './DemoBase';
|
||||
import { Component, ECSComponent, WorkerEntitySystem, EntitySystem, Matcher, Entity, ECSSystem, PlatformManager, Time } from '@esengine/ecs-framework';
|
||||
import { BrowserAdapter } from '../platform/BrowserAdapter';
|
||||
|
||||
// ============ 组件定义 ============
|
||||
|
||||
@ECSComponent('WorkerDemo_Position')
|
||||
class Position extends Component {
|
||||
x: number = 0;
|
||||
y: number = 0;
|
||||
|
||||
constructor(x: number = 0, y: number = 0) {
|
||||
super();
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
set(x: number, y: number): void {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
}
|
||||
|
||||
@ECSComponent('WorkerDemo_Velocity')
|
||||
class Velocity extends Component {
|
||||
dx: number = 0;
|
||||
dy: number = 0;
|
||||
|
||||
constructor(dx: number = 0, dy: number = 0) {
|
||||
super();
|
||||
this.dx = dx;
|
||||
this.dy = dy;
|
||||
}
|
||||
|
||||
set(dx: number, dy: number): void {
|
||||
this.dx = dx;
|
||||
this.dy = dy;
|
||||
}
|
||||
}
|
||||
|
||||
@ECSComponent('WorkerDemo_Physics')
|
||||
class Physics extends Component {
|
||||
mass: number = 1;
|
||||
bounce: number = 0.8;
|
||||
friction: number = 0.95;
|
||||
|
||||
constructor(mass: number = 1, bounce: number = 0.8, friction: number = 0.95) {
|
||||
super();
|
||||
this.mass = mass;
|
||||
this.bounce = bounce;
|
||||
this.friction = friction;
|
||||
}
|
||||
}
|
||||
|
||||
@ECSComponent('WorkerDemo_Renderable')
|
||||
class Renderable extends Component {
|
||||
color: string = '#ffffff';
|
||||
size: number = 5;
|
||||
shape: 'circle' | 'square' = 'circle';
|
||||
|
||||
constructor(color: string = '#ffffff', size: number = 5, shape: 'circle' | 'square' = 'circle') {
|
||||
super();
|
||||
this.color = color;
|
||||
this.size = size;
|
||||
this.shape = shape;
|
||||
}
|
||||
}
|
||||
|
||||
@ECSComponent('WorkerDemo_Lifetime')
|
||||
class Lifetime extends Component {
|
||||
maxAge: number = 5;
|
||||
currentAge: number = 0;
|
||||
|
||||
constructor(maxAge: number = 5) {
|
||||
super();
|
||||
this.maxAge = maxAge;
|
||||
this.currentAge = 0;
|
||||
}
|
||||
|
||||
isDead(): boolean {
|
||||
return this.currentAge >= this.maxAge;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 系统定义 ============
|
||||
|
||||
interface PhysicsEntityData {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
mass: number;
|
||||
bounce: number;
|
||||
friction: number;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
interface PhysicsConfig {
|
||||
gravity: number;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
groundFriction: number;
|
||||
}
|
||||
|
||||
@ECSSystem('PhysicsWorkerSystem')
|
||||
class PhysicsWorkerSystem extends WorkerEntitySystem<PhysicsEntityData> {
|
||||
private physicsConfig: PhysicsConfig;
|
||||
|
||||
constructor(enableWorker: boolean, canvasWidth: number, canvasHeight: number) {
|
||||
const defaultConfig = {
|
||||
gravity: 100,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
groundFriction: 0.98
|
||||
};
|
||||
|
||||
const isSharedArrayBufferAvailable = typeof SharedArrayBuffer !== 'undefined' && self.crossOriginIsolated;
|
||||
|
||||
super(
|
||||
Matcher.empty().all(Position, Velocity, Physics),
|
||||
{
|
||||
enableWorker,
|
||||
workerCount: isSharedArrayBufferAvailable ? (navigator.hardwareConcurrency || 2) : 1,
|
||||
systemConfig: defaultConfig,
|
||||
useSharedArrayBuffer: true
|
||||
}
|
||||
);
|
||||
|
||||
this.physicsConfig = defaultConfig;
|
||||
}
|
||||
|
||||
protected extractEntityData(entity: Entity): PhysicsEntityData {
|
||||
const position = entity.getComponent(Position)!;
|
||||
const velocity = entity.getComponent(Velocity)!;
|
||||
const physics = entity.getComponent(Physics)!;
|
||||
const renderable = entity.getComponent(Renderable)!;
|
||||
|
||||
return {
|
||||
id: entity.id,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
dx: velocity.dx,
|
||||
dy: velocity.dy,
|
||||
mass: physics.mass,
|
||||
bounce: physics.bounce,
|
||||
friction: physics.friction,
|
||||
radius: renderable.size
|
||||
};
|
||||
}
|
||||
|
||||
protected workerProcess(
|
||||
entities: PhysicsEntityData[],
|
||||
deltaTime: number,
|
||||
systemConfig?: PhysicsConfig
|
||||
): PhysicsEntityData[] {
|
||||
const config = systemConfig || this.physicsConfig;
|
||||
const result = entities.map(e => ({ ...e }));
|
||||
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const entity = result[i];
|
||||
|
||||
entity.dy += config.gravity * deltaTime;
|
||||
entity.x += entity.dx * deltaTime;
|
||||
entity.y += entity.dy * deltaTime;
|
||||
|
||||
if (entity.x <= entity.radius) {
|
||||
entity.x = entity.radius;
|
||||
entity.dx = -entity.dx * entity.bounce;
|
||||
} else if (entity.x >= config.canvasWidth - entity.radius) {
|
||||
entity.x = config.canvasWidth - entity.radius;
|
||||
entity.dx = -entity.dx * entity.bounce;
|
||||
}
|
||||
|
||||
if (entity.y <= entity.radius) {
|
||||
entity.y = entity.radius;
|
||||
entity.dy = -entity.dy * entity.bounce;
|
||||
} else if (entity.y >= config.canvasHeight - entity.radius) {
|
||||
entity.y = config.canvasHeight - entity.radius;
|
||||
entity.dy = -entity.dy * entity.bounce;
|
||||
entity.dx *= config.groundFriction;
|
||||
}
|
||||
|
||||
entity.dx *= entity.friction;
|
||||
entity.dy *= entity.friction;
|
||||
}
|
||||
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
for (let j = i + 1; j < result.length; j++) {
|
||||
const ball1 = result[i];
|
||||
const ball2 = result[j];
|
||||
|
||||
const dx = ball2.x - ball1.x;
|
||||
const dy = ball2.y - ball1.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const minDistance = ball1.radius + ball2.radius;
|
||||
|
||||
if (distance < minDistance && distance > 0) {
|
||||
const nx = dx / distance;
|
||||
const ny = dy / distance;
|
||||
|
||||
const overlap = minDistance - distance;
|
||||
const separationX = nx * overlap * 0.5;
|
||||
const separationY = ny * overlap * 0.5;
|
||||
|
||||
ball1.x -= separationX;
|
||||
ball1.y -= separationY;
|
||||
ball2.x += separationX;
|
||||
ball2.y += separationY;
|
||||
|
||||
const relativeVelocityX = ball2.dx - ball1.dx;
|
||||
const relativeVelocityY = ball2.dy - ball1.dy;
|
||||
const velocityAlongNormal = relativeVelocityX * nx + relativeVelocityY * ny;
|
||||
|
||||
if (velocityAlongNormal > 0) continue;
|
||||
|
||||
const restitution = (ball1.bounce + ball2.bounce) * 0.5;
|
||||
const impulseScalar = -(1 + restitution) * velocityAlongNormal / (1/ball1.mass + 1/ball2.mass);
|
||||
|
||||
const impulseX = impulseScalar * nx;
|
||||
const impulseY = impulseScalar * ny;
|
||||
|
||||
ball1.dx -= impulseX / ball1.mass;
|
||||
ball1.dy -= impulseY / ball1.mass;
|
||||
ball2.dx += impulseX / ball2.mass;
|
||||
ball2.dy += impulseY / ball2.mass;
|
||||
|
||||
const energyLoss = 0.98;
|
||||
ball1.dx *= energyLoss;
|
||||
ball1.dy *= energyLoss;
|
||||
ball2.dx *= energyLoss;
|
||||
ball2.dy *= energyLoss;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected applyResult(entity: Entity, result: PhysicsEntityData): void {
|
||||
if (!entity || !entity.enabled) return;
|
||||
|
||||
const position = entity.getComponent(Position);
|
||||
const velocity = entity.getComponent(Velocity);
|
||||
|
||||
if (!position || !velocity) return;
|
||||
|
||||
position.set(result.x, result.y);
|
||||
velocity.set(result.dx, result.dy);
|
||||
}
|
||||
|
||||
public updatePhysicsConfig(newConfig: Partial<PhysicsConfig>): void {
|
||||
Object.assign(this.physicsConfig, newConfig);
|
||||
this.updateConfig({ systemConfig: this.physicsConfig });
|
||||
}
|
||||
|
||||
public getPhysicsConfig(): PhysicsConfig {
|
||||
return { ...this.physicsConfig };
|
||||
}
|
||||
|
||||
protected getDefaultEntityDataSize(): number {
|
||||
return 9;
|
||||
}
|
||||
|
||||
protected writeEntityToBuffer(entityData: PhysicsEntityData, offset: number): void {
|
||||
const sharedArray = (this as any).sharedFloatArray as Float32Array;
|
||||
if (!sharedArray) return;
|
||||
|
||||
// 在第一个位置存储当前实体数量
|
||||
const currentEntityCount = Math.floor(offset / 9) + 1;
|
||||
sharedArray[0] = currentEntityCount;
|
||||
|
||||
// 数据从索引9开始存储(第一个9个位置用作元数据区域)
|
||||
const dataOffset = offset + 9;
|
||||
sharedArray[dataOffset + 0] = entityData.id;
|
||||
sharedArray[dataOffset + 1] = entityData.x;
|
||||
sharedArray[dataOffset + 2] = entityData.y;
|
||||
sharedArray[dataOffset + 3] = entityData.dx;
|
||||
sharedArray[dataOffset + 4] = entityData.dy;
|
||||
sharedArray[dataOffset + 5] = entityData.mass;
|
||||
sharedArray[dataOffset + 6] = entityData.bounce;
|
||||
sharedArray[dataOffset + 7] = entityData.friction;
|
||||
sharedArray[dataOffset + 8] = entityData.radius;
|
||||
}
|
||||
|
||||
protected readEntityFromBuffer(offset: number): PhysicsEntityData | null {
|
||||
const sharedArray = (this as any).sharedFloatArray as Float32Array;
|
||||
if (!sharedArray) return null;
|
||||
|
||||
// 数据从索引9开始存储
|
||||
const dataOffset = offset + 9;
|
||||
return {
|
||||
id: sharedArray[dataOffset + 0],
|
||||
x: sharedArray[dataOffset + 1],
|
||||
y: sharedArray[dataOffset + 2],
|
||||
dx: sharedArray[dataOffset + 3],
|
||||
dy: sharedArray[dataOffset + 4],
|
||||
mass: sharedArray[dataOffset + 5],
|
||||
bounce: sharedArray[dataOffset + 6],
|
||||
friction: sharedArray[dataOffset + 7],
|
||||
radius: sharedArray[dataOffset + 8]
|
||||
};
|
||||
}
|
||||
|
||||
protected getSharedArrayBufferProcessFunction(): any {
|
||||
return function(sharedFloatArray: Float32Array, startIndex: number, endIndex: number, deltaTime: number, systemConfig?: any) {
|
||||
const config = systemConfig || {
|
||||
gravity: 100,
|
||||
canvasWidth: 800,
|
||||
canvasHeight: 600,
|
||||
groundFriction: 0.98
|
||||
};
|
||||
|
||||
const actualEntityCount = sharedFloatArray[0];
|
||||
|
||||
// 基础物理更新
|
||||
for (let i = startIndex; i < endIndex && i < actualEntityCount; i++) {
|
||||
const offset = i * 9 + 9;
|
||||
|
||||
const id = sharedFloatArray[offset + 0];
|
||||
if (id === 0) continue;
|
||||
|
||||
let x = sharedFloatArray[offset + 1];
|
||||
let y = sharedFloatArray[offset + 2];
|
||||
let dx = sharedFloatArray[offset + 3];
|
||||
let dy = sharedFloatArray[offset + 4];
|
||||
const bounce = sharedFloatArray[offset + 6];
|
||||
const friction = sharedFloatArray[offset + 7];
|
||||
const radius = sharedFloatArray[offset + 8];
|
||||
|
||||
// 应用重力
|
||||
dy += config.gravity * deltaTime;
|
||||
|
||||
// 更新位置
|
||||
x += dx * deltaTime;
|
||||
y += dy * deltaTime;
|
||||
|
||||
// 边界碰撞
|
||||
if (x <= radius) {
|
||||
x = radius;
|
||||
dx = -dx * bounce;
|
||||
} else if (x >= config.canvasWidth - radius) {
|
||||
x = config.canvasWidth - radius;
|
||||
dx = -dx * bounce;
|
||||
}
|
||||
|
||||
if (y <= radius) {
|
||||
y = radius;
|
||||
dy = -dy * bounce;
|
||||
} else if (y >= config.canvasHeight - radius) {
|
||||
y = config.canvasHeight - radius;
|
||||
dy = -dy * bounce;
|
||||
dx *= config.groundFriction;
|
||||
}
|
||||
|
||||
// 空气阻力
|
||||
dx *= friction;
|
||||
dy *= friction;
|
||||
|
||||
// 写回数据
|
||||
sharedFloatArray[offset + 1] = x;
|
||||
sharedFloatArray[offset + 2] = y;
|
||||
sharedFloatArray[offset + 3] = dx;
|
||||
sharedFloatArray[offset + 4] = dy;
|
||||
}
|
||||
|
||||
// 碰撞检测
|
||||
for (let i = startIndex; i < endIndex && i < actualEntityCount; i++) {
|
||||
const offset1 = i * 9 + 9;
|
||||
const id1 = sharedFloatArray[offset1 + 0];
|
||||
if (id1 === 0) continue;
|
||||
|
||||
let x1 = sharedFloatArray[offset1 + 1];
|
||||
let y1 = sharedFloatArray[offset1 + 2];
|
||||
let dx1 = sharedFloatArray[offset1 + 3];
|
||||
let dy1 = sharedFloatArray[offset1 + 4];
|
||||
const mass1 = sharedFloatArray[offset1 + 5];
|
||||
const bounce1 = sharedFloatArray[offset1 + 6];
|
||||
const radius1 = sharedFloatArray[offset1 + 8];
|
||||
|
||||
for (let j = 0; j < actualEntityCount; j++) {
|
||||
if (i === j) continue;
|
||||
|
||||
const offset2 = j * 9 + 9;
|
||||
const id2 = sharedFloatArray[offset2 + 0];
|
||||
if (id2 === 0) continue;
|
||||
|
||||
const x2 = sharedFloatArray[offset2 + 1];
|
||||
const y2 = sharedFloatArray[offset2 + 2];
|
||||
const dx2 = sharedFloatArray[offset2 + 3];
|
||||
const dy2 = sharedFloatArray[offset2 + 4];
|
||||
const mass2 = sharedFloatArray[offset2 + 5];
|
||||
const bounce2 = sharedFloatArray[offset2 + 6];
|
||||
const radius2 = sharedFloatArray[offset2 + 8];
|
||||
|
||||
if (isNaN(x2) || isNaN(y2) || isNaN(radius2) || radius2 <= 0) continue;
|
||||
|
||||
const deltaX = x2 - x1;
|
||||
const deltaY = y2 - y1;
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
const minDistance = radius1 + radius2;
|
||||
|
||||
if (distance < minDistance && distance > 0) {
|
||||
const nx = deltaX / distance;
|
||||
const ny = deltaY / distance;
|
||||
|
||||
const overlap = minDistance - distance;
|
||||
const separationX = nx * overlap * 0.5;
|
||||
const separationY = ny * overlap * 0.5;
|
||||
|
||||
x1 -= separationX;
|
||||
y1 -= separationY;
|
||||
|
||||
const relativeVelocityX = dx2 - dx1;
|
||||
const relativeVelocityY = dy2 - dy1;
|
||||
const velocityAlongNormal = relativeVelocityX * nx + relativeVelocityY * ny;
|
||||
|
||||
if (velocityAlongNormal > 0) continue;
|
||||
|
||||
const restitution = (bounce1 + bounce2) * 0.5;
|
||||
const impulseScalar = -(1 + restitution) * velocityAlongNormal / (1/mass1 + 1/mass2);
|
||||
|
||||
const impulseX = impulseScalar * nx;
|
||||
const impulseY = impulseScalar * ny;
|
||||
|
||||
dx1 -= impulseX / mass1;
|
||||
dy1 -= impulseY / mass1;
|
||||
|
||||
const energyLoss = 0.98;
|
||||
dx1 *= energyLoss;
|
||||
dy1 *= energyLoss;
|
||||
}
|
||||
}
|
||||
|
||||
sharedFloatArray[offset1 + 1] = x1;
|
||||
sharedFloatArray[offset1 + 2] = y1;
|
||||
sharedFloatArray[offset1 + 3] = dx1;
|
||||
sharedFloatArray[offset1 + 4] = dy1;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ECSSystem('RenderSystem')
|
||||
class RenderSystem extends EntitySystem {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
super(Matcher.empty().all(Position, Renderable));
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d')!;
|
||||
}
|
||||
|
||||
protected override process(entities: Entity[]): void {
|
||||
this.ctx.fillStyle = '#000';
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
for (const entity of entities) {
|
||||
const position = entity.getComponent(Position);
|
||||
const renderable = entity.getComponent(Renderable);
|
||||
|
||||
if (!position || !renderable) continue;
|
||||
|
||||
this.ctx.fillStyle = renderable.color;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(position.x, position.y, renderable.size, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ECSSystem('LifetimeSystem')
|
||||
class LifetimeSystem extends EntitySystem {
|
||||
constructor() {
|
||||
super(Matcher.empty().all(Lifetime));
|
||||
}
|
||||
|
||||
protected override process(entities: Entity[]): void {
|
||||
const deltaTime = Time.deltaTime;
|
||||
|
||||
for (const entity of entities) {
|
||||
const lifetime = entity.getComponent(Lifetime);
|
||||
if (!lifetime) continue;
|
||||
|
||||
lifetime.currentAge += deltaTime;
|
||||
if (lifetime.isDead()) {
|
||||
entity.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Demo类 ============
|
||||
|
||||
export class WorkerSystemDemo extends DemoBase {
|
||||
private physicsSystem!: PhysicsWorkerSystem;
|
||||
private renderSystem!: RenderSystem;
|
||||
private lifetimeSystem!: LifetimeSystem;
|
||||
private currentFPS = 0;
|
||||
private frameCount = 0;
|
||||
private fpsUpdateTime = 0;
|
||||
private elements: { [key: string]: HTMLElement } = {};
|
||||
|
||||
getInfo(): DemoInfo {
|
||||
return {
|
||||
id: 'worker-system',
|
||||
name: 'Worker System',
|
||||
description: '演示 ECS 框架中的多线程物理计算能力',
|
||||
category: '核心功能',
|
||||
icon: '⚙️'
|
||||
};
|
||||
}
|
||||
|
||||
setup(): void {
|
||||
// 注册浏览器平台适配器
|
||||
const browserAdapter = new BrowserAdapter();
|
||||
PlatformManager.getInstance().registerAdapter(browserAdapter);
|
||||
|
||||
// 初始化系统
|
||||
this.physicsSystem = new PhysicsWorkerSystem(true, this.canvas.width, this.canvas.height);
|
||||
this.renderSystem = new RenderSystem(this.canvas);
|
||||
this.lifetimeSystem = new LifetimeSystem();
|
||||
|
||||
this.physicsSystem.updateOrder = 1;
|
||||
this.lifetimeSystem.updateOrder = 2;
|
||||
this.renderSystem.updateOrder = 3;
|
||||
|
||||
this.scene.addSystem(this.physicsSystem);
|
||||
this.scene.addSystem(this.lifetimeSystem);
|
||||
this.scene.addSystem(this.renderSystem);
|
||||
|
||||
// 创建控制面板
|
||||
this.createControls();
|
||||
|
||||
// 初始化UI元素引用
|
||||
this.initializeUIElements();
|
||||
this.bindEvents();
|
||||
|
||||
// 生成初始实体
|
||||
this.spawnInitialEntities(1000);
|
||||
}
|
||||
|
||||
createControls(): void {
|
||||
this.controlPanel.innerHTML = `
|
||||
<div style="background: #2a2a2a; padding: 20px; border-radius: 8px; height: 100%; overflow-y: auto;">
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px; color: #ccc;">实体数量:</label>
|
||||
<input type="range" id="entityCount" min="100" max="10000" value="1000" step="100"
|
||||
style="width: 100%; margin-bottom: 5px;">
|
||||
<span id="entityCountValue" style="color: #fff;">1000</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px; color: #ccc;">Worker 设置:</label>
|
||||
<button id="toggleWorker" style="width: 100%; padding: 8px; margin-bottom: 5px;
|
||||
background: #4a9eff; color: white; border: none; border-radius: 4px; cursor: pointer;">
|
||||
禁用 Worker
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<button id="spawnParticles" style="width: 100%; padding: 8px; margin-bottom: 5px;
|
||||
background: #4a9eff; color: white; border: none; border-radius: 4px; cursor: pointer;">
|
||||
生成粒子爆炸
|
||||
</button>
|
||||
<button id="clearEntities" style="width: 100%; padding: 8px; margin-bottom: 5px;
|
||||
background: #4a9eff; color: white; border: none; border-radius: 4px; cursor: pointer;">
|
||||
清空所有实体
|
||||
</button>
|
||||
<button id="resetDemo" style="width: 100%; padding: 8px;
|
||||
background: #4a9eff; color: white; border: none; border-radius: 4px; cursor: pointer;">
|
||||
重置演示
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px; color: #ccc;">物理参数:</label>
|
||||
<input type="range" id="gravity" min="0" max="500" value="100" step="10"
|
||||
style="width: 100%; margin-bottom: 5px;">
|
||||
<label style="color: #ccc;">重力: <span id="gravityValue">100</span></label>
|
||||
|
||||
<input type="range" id="friction" min="0" max="100" value="95" step="5"
|
||||
style="width: 100%; margin-top: 10px; margin-bottom: 5px;">
|
||||
<label style="color: #ccc;">摩擦力: <span id="frictionValue">95%</span></label>
|
||||
</div>
|
||||
|
||||
<div style="background: #1a1a1a; padding: 15px; border-radius: 8px; font-family: monospace; font-size: 12px;">
|
||||
<h3 style="margin-top: 0; color: #4a9eff;">性能统计</h3>
|
||||
<div style="margin: 5px 0; color: #ccc;">FPS: <span id="fps" style="color: #4eff4a;">0</span></div>
|
||||
<div style="margin: 5px 0; color: #ccc;">实体数量: <span id="entityCountStat" style="color: #fff;">0</span></div>
|
||||
<div style="margin: 5px 0; color: #ccc;">Worker状态: <span id="workerStatus" style="color: #ff4a4a;">未启用</span></div>
|
||||
<div style="margin: 5px 0; color: #ccc;">Worker负载: <span id="workerLoad" style="color: #fff;">N/A</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected render(): void {
|
||||
this.frameCount++;
|
||||
const currentTime = performance.now();
|
||||
|
||||
if (currentTime - this.fpsUpdateTime >= 1000) {
|
||||
this.currentFPS = this.frameCount;
|
||||
this.frameCount = 0;
|
||||
this.fpsUpdateTime = currentTime;
|
||||
}
|
||||
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
private initializeUIElements(): void {
|
||||
const elementIds = [
|
||||
'entityCount', 'entityCountValue', 'toggleWorker',
|
||||
'gravity', 'gravityValue', 'friction', 'frictionValue', 'spawnParticles',
|
||||
'clearEntities', 'resetDemo', 'fps', 'entityCountStat', 'workerStatus', 'workerLoad'
|
||||
];
|
||||
|
||||
for (const id of elementIds) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
this.elements[id] = element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bindEvents(): void {
|
||||
if (this.elements.entityCount && this.elements.entityCountValue) {
|
||||
const slider = this.elements.entityCount as HTMLInputElement;
|
||||
slider.addEventListener('input', () => {
|
||||
this.elements.entityCountValue.textContent = slider.value;
|
||||
});
|
||||
|
||||
slider.addEventListener('change', () => {
|
||||
const count = parseInt(slider.value);
|
||||
this.spawnInitialEntities(count);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.elements.toggleWorker) {
|
||||
this.elements.toggleWorker.addEventListener('click', () => {
|
||||
const workerEnabled = this.toggleWorker();
|
||||
this.elements.toggleWorker.textContent = workerEnabled ? '禁用 Worker' : '启用 Worker';
|
||||
});
|
||||
}
|
||||
|
||||
if (this.elements.gravity && this.elements.gravityValue) {
|
||||
const slider = this.elements.gravity as HTMLInputElement;
|
||||
slider.addEventListener('input', () => {
|
||||
this.elements.gravityValue.textContent = slider.value;
|
||||
});
|
||||
|
||||
slider.addEventListener('change', () => {
|
||||
const gravity = parseInt(slider.value);
|
||||
this.updateWorkerConfig({ gravity });
|
||||
});
|
||||
}
|
||||
|
||||
if (this.elements.friction && this.elements.frictionValue) {
|
||||
const slider = this.elements.friction as HTMLInputElement;
|
||||
slider.addEventListener('input', () => {
|
||||
const value = parseInt(slider.value);
|
||||
this.elements.frictionValue.textContent = `${value}%`;
|
||||
});
|
||||
|
||||
slider.addEventListener('change', () => {
|
||||
const friction = parseInt(slider.value) / 100;
|
||||
this.updateWorkerConfig({ friction });
|
||||
});
|
||||
}
|
||||
|
||||
if (this.elements.spawnParticles) {
|
||||
this.elements.spawnParticles.addEventListener('click', () => {
|
||||
const centerX = this.canvas.width / 2;
|
||||
const centerY = this.canvas.height / 2;
|
||||
this.spawnParticleExplosion(centerX, centerY, 100);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.elements.clearEntities) {
|
||||
this.elements.clearEntities.addEventListener('click', () => {
|
||||
this.clearAllEntities();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.elements.resetDemo) {
|
||||
this.elements.resetDemo.addEventListener('click', () => {
|
||||
(this.elements.entityCount as HTMLInputElement).value = '1000';
|
||||
this.elements.entityCountValue.textContent = '1000';
|
||||
(this.elements.gravity as HTMLInputElement).value = '100';
|
||||
this.elements.gravityValue.textContent = '100';
|
||||
(this.elements.friction as HTMLInputElement).value = '95';
|
||||
this.elements.frictionValue.textContent = '95%';
|
||||
|
||||
this.spawnInitialEntities(1000);
|
||||
this.updateWorkerConfig({ gravity: 100, friction: 0.95 });
|
||||
});
|
||||
}
|
||||
|
||||
this.canvas.addEventListener('click', (event) => {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
this.spawnParticleExplosion(x, y, 30);
|
||||
});
|
||||
}
|
||||
|
||||
private updateUI(): void {
|
||||
const workerInfo = this.physicsSystem.getWorkerInfo();
|
||||
|
||||
if (this.elements.fps) {
|
||||
this.elements.fps.textContent = this.currentFPS.toString();
|
||||
}
|
||||
|
||||
if (this.elements.entityCountStat) {
|
||||
this.elements.entityCountStat.textContent = this.scene.entities.count.toString();
|
||||
}
|
||||
|
||||
if (this.elements.workerStatus) {
|
||||
if (workerInfo.enabled) {
|
||||
this.elements.workerStatus.textContent = `启用 (${workerInfo.workerCount} Workers)`;
|
||||
this.elements.workerStatus.style.color = '#4eff4a';
|
||||
} else {
|
||||
this.elements.workerStatus.textContent = '禁用';
|
||||
this.elements.workerStatus.style.color = '#ff4a4a';
|
||||
}
|
||||
}
|
||||
|
||||
if (this.elements.workerLoad) {
|
||||
const entityCount = this.scene.entities.count;
|
||||
if (workerInfo.enabled && entityCount > 0) {
|
||||
const entitiesPerWorker = Math.ceil(entityCount / workerInfo.workerCount);
|
||||
this.elements.workerLoad.textContent = `${entitiesPerWorker}/Worker (共${workerInfo.workerCount}个)`;
|
||||
} else {
|
||||
this.elements.workerLoad.textContent = 'N/A';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private spawnInitialEntities(count: number = 1000): void {
|
||||
this.clearAllEntities();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.createParticle();
|
||||
}
|
||||
}
|
||||
|
||||
private createParticle(): void {
|
||||
const entity = this.scene.createEntity(`Particle_${Date.now()}_${Math.random()}`);
|
||||
|
||||
const x = Math.random() * (this.canvas.width - 20) + 10;
|
||||
const y = Math.random() * (this.canvas.height - 20) + 10;
|
||||
const dx = (Math.random() - 0.5) * 200;
|
||||
const dy = (Math.random() - 0.5) * 200;
|
||||
const mass = Math.random() * 3 + 2;
|
||||
const bounce = 0.85 + Math.random() * 0.15;
|
||||
const friction = 0.998 + Math.random() * 0.002;
|
||||
|
||||
const colors = [
|
||||
'#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff', '#44ffff', '#ffffff',
|
||||
'#ff8844', '#88ff44', '#4488ff', '#ff4488', '#88ff88', '#8888ff', '#ffaa44'
|
||||
];
|
||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||
const size = Math.random() * 6 + 3;
|
||||
|
||||
entity.addComponent(new Position(x, y));
|
||||
entity.addComponent(new Velocity(dx, dy));
|
||||
entity.addComponent(new Physics(mass, bounce, friction));
|
||||
entity.addComponent(new Renderable(color, size, 'circle'));
|
||||
entity.addComponent(new Lifetime(5 + Math.random() * 10));
|
||||
}
|
||||
|
||||
private spawnParticleExplosion(centerX: number, centerY: number, count: number = 50): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const entity = this.scene.createEntity(`Explosion_${Date.now()}_${i}`);
|
||||
|
||||
const angle = (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.5;
|
||||
const distance = Math.random() * 30;
|
||||
const x = centerX + Math.cos(angle) * distance;
|
||||
const y = centerY + Math.sin(angle) * distance;
|
||||
|
||||
const speed = 100 + Math.random() * 150;
|
||||
const dx = Math.cos(angle) * speed;
|
||||
const dy = Math.sin(angle) * speed;
|
||||
const mass = 0.5 + Math.random() * 1;
|
||||
const bounce = 0.8 + Math.random() * 0.2;
|
||||
|
||||
const colors = ['#ffaa00', '#ff6600', '#ff0066', '#ff3300', '#ffff00'];
|
||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||
const size = Math.random() * 4 + 2;
|
||||
|
||||
entity.addComponent(new Position(x, y));
|
||||
entity.addComponent(new Velocity(dx, dy));
|
||||
entity.addComponent(new Physics(mass, bounce, 0.999));
|
||||
entity.addComponent(new Renderable(color, size, 'circle'));
|
||||
entity.addComponent(new Lifetime(2 + Math.random() * 3));
|
||||
}
|
||||
}
|
||||
|
||||
private clearAllEntities(): void {
|
||||
const entities = [...this.scene.entities.buffer];
|
||||
for (const entity of entities) {
|
||||
entity.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private toggleWorker(): boolean {
|
||||
const workerInfo = this.physicsSystem.getWorkerInfo();
|
||||
const newWorkerEnabled = !workerInfo.enabled;
|
||||
|
||||
// 保存当前物理配置
|
||||
const currentConfig = this.physicsSystem.getPhysicsConfig();
|
||||
|
||||
this.scene.removeSystem(this.physicsSystem);
|
||||
this.physicsSystem = new PhysicsWorkerSystem(newWorkerEnabled, this.canvas.width, this.canvas.height);
|
||||
this.physicsSystem.updateOrder = 1;
|
||||
|
||||
// 恢复物理配置
|
||||
this.physicsSystem.updatePhysicsConfig(currentConfig);
|
||||
|
||||
this.scene.addSystem(this.physicsSystem);
|
||||
|
||||
return newWorkerEnabled;
|
||||
}
|
||||
|
||||
private updateWorkerConfig(config: { gravity?: number; friction?: number }): void {
|
||||
if (config.gravity !== undefined || config.friction !== undefined) {
|
||||
const physicsConfig = this.physicsSystem.getPhysicsConfig();
|
||||
this.physicsSystem.updatePhysicsConfig({
|
||||
gravity: config.gravity ?? physicsConfig.gravity,
|
||||
groundFriction: config.friction ?? physicsConfig.groundFriction
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
12
examples/core-demos/src/demos/index.ts
Normal file
12
examples/core-demos/src/demos/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { DemoBase } from './DemoBase';
|
||||
import { SerializationDemo } from './SerializationDemo';
|
||||
import { WorkerSystemDemo } from './WorkerSystemDemo';
|
||||
|
||||
export { DemoBase, SerializationDemo, WorkerSystemDemo };
|
||||
|
||||
// Demo注册表
|
||||
export const DEMO_REGISTRY: typeof DemoBase[] = [
|
||||
SerializationDemo,
|
||||
WorkerSystemDemo,
|
||||
// 更多demos可以在这里添加
|
||||
];
|
||||
171
examples/core-demos/src/main.ts
Normal file
171
examples/core-demos/src/main.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { DEMO_REGISTRY, DemoBase } from './demos';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
|
||||
class DemoManager {
|
||||
private demos: Map<string, typeof DemoBase> = new Map();
|
||||
private currentDemo: DemoBase | null = null;
|
||||
private canvas: HTMLCanvasElement;
|
||||
private controlPanel: HTMLElement;
|
||||
|
||||
constructor() {
|
||||
// 初始化ECS Core
|
||||
Core.create({
|
||||
debug: true,
|
||||
enableEntitySystems: true
|
||||
});
|
||||
|
||||
this.canvas = document.getElementById('demoCanvas') as HTMLCanvasElement;
|
||||
this.controlPanel = document.getElementById('controlPanel') as HTMLElement;
|
||||
|
||||
// 注册所有demos
|
||||
for (const DemoClass of DEMO_REGISTRY) {
|
||||
const tempInstance = new DemoClass(this.canvas, this.controlPanel);
|
||||
const info = tempInstance.getInfo();
|
||||
this.demos.set(info.id, DemoClass);
|
||||
tempInstance.destroy();
|
||||
}
|
||||
|
||||
// 渲染demo列表
|
||||
this.renderDemoList();
|
||||
|
||||
// 自动加载第一个demo
|
||||
const firstDemo = DEMO_REGISTRY[0];
|
||||
if (firstDemo) {
|
||||
const tempInstance = new firstDemo(this.canvas, this.controlPanel);
|
||||
const info = tempInstance.getInfo();
|
||||
tempInstance.destroy();
|
||||
this.loadDemo(info.id);
|
||||
}
|
||||
}
|
||||
|
||||
private renderDemoList() {
|
||||
const demoList = document.getElementById('demoList')!;
|
||||
|
||||
// 按分类组织demos
|
||||
const categories = new Map<string, typeof DemoBase[]>();
|
||||
|
||||
for (const DemoClass of DEMO_REGISTRY) {
|
||||
const tempInstance = new DemoClass(this.canvas, this.controlPanel);
|
||||
const info = tempInstance.getInfo();
|
||||
tempInstance.destroy();
|
||||
|
||||
if (!categories.has(info.category)) {
|
||||
categories.set(info.category, []);
|
||||
}
|
||||
categories.get(info.category)!.push(DemoClass);
|
||||
}
|
||||
|
||||
// 渲染分类和demos
|
||||
let html = '';
|
||||
for (const [category, demoClasses] of categories) {
|
||||
html += `<div class="demo-category">`;
|
||||
html += `<div class="category-title">${category}</div>`;
|
||||
|
||||
for (const DemoClass of demoClasses) {
|
||||
const tempInstance = new DemoClass(this.canvas, this.controlPanel);
|
||||
const info = tempInstance.getInfo();
|
||||
tempInstance.destroy();
|
||||
|
||||
html += `
|
||||
<div class="demo-item" data-demo-id="${info.id}">
|
||||
<div class="demo-icon">${info.icon}</div>
|
||||
<div class="demo-info">
|
||||
<div class="demo-name">${info.name}</div>
|
||||
<div class="demo-desc">${info.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
demoList.innerHTML = html;
|
||||
|
||||
// 绑定点击事件
|
||||
demoList.querySelectorAll('.demo-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const demoId = item.getAttribute('data-demo-id')!;
|
||||
this.loadDemo(demoId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private loadDemo(demoId: string) {
|
||||
// 停止并销毁当前demo
|
||||
if (this.currentDemo) {
|
||||
this.currentDemo.destroy();
|
||||
this.currentDemo = null;
|
||||
}
|
||||
|
||||
// 显示加载动画
|
||||
const loading = document.getElementById('loading')!;
|
||||
loading.style.display = 'block';
|
||||
|
||||
// 延迟加载,给用户反馈
|
||||
setTimeout(() => {
|
||||
const DemoClass = this.demos.get(demoId);
|
||||
if (!DemoClass) {
|
||||
console.error(`Demo ${demoId} not found`);
|
||||
loading.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建新demo
|
||||
this.currentDemo = new DemoClass(this.canvas, this.controlPanel);
|
||||
const info = this.currentDemo.getInfo();
|
||||
|
||||
// 更新页面标题和描述
|
||||
document.getElementById('demoTitle')!.textContent = info.name;
|
||||
document.getElementById('demoDescription')!.textContent = info.description;
|
||||
|
||||
// 设置demo
|
||||
this.currentDemo.setup();
|
||||
|
||||
// 显示控制面板
|
||||
this.controlPanel.style.display = 'block';
|
||||
|
||||
// 启动demo
|
||||
this.currentDemo.start();
|
||||
|
||||
// 更新菜单选中状态
|
||||
document.querySelectorAll('.demo-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
if (item.getAttribute('data-demo-id') === demoId) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
loading.style.display = 'none';
|
||||
|
||||
console.log(`✅ Demo "${info.name}" loaded successfully`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load demo ${demoId}:`, error);
|
||||
loading.style.display = 'none';
|
||||
this.showError('加载演示失败:' + (error as Error).message);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
private showError(message: string) {
|
||||
const toast = document.getElementById('toast')!;
|
||||
const toastMessage = document.getElementById('toastMessage')!;
|
||||
const toastIcon = toast.querySelector('.toast-icon')!;
|
||||
|
||||
toastIcon.textContent = '❌';
|
||||
toastMessage.textContent = message;
|
||||
toast.style.borderColor = '#f5576c';
|
||||
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
toast.style.borderColor = '#667eea';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
new DemoManager();
|
||||
});
|
||||
204
examples/core-demos/src/platform/BrowserAdapter.ts
Normal file
204
examples/core-demos/src/platform/BrowserAdapter.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import type {
|
||||
IPlatformAdapter,
|
||||
PlatformWorker,
|
||||
WorkerCreationOptions,
|
||||
PlatformConfig
|
||||
} from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 浏览器平台适配器
|
||||
*/
|
||||
export class BrowserAdapter implements IPlatformAdapter {
|
||||
public readonly name = 'browser';
|
||||
public readonly version: string;
|
||||
|
||||
constructor() {
|
||||
this.version = this.getBrowserInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否支持Worker
|
||||
*/
|
||||
public isWorkerSupported(): boolean {
|
||||
return typeof Worker !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否支持SharedArrayBuffer
|
||||
*/
|
||||
public isSharedArrayBufferSupported(): boolean {
|
||||
return typeof SharedArrayBuffer !== 'undefined' && this.checkSharedArrayBufferEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取硬件并发数(CPU核心数)
|
||||
*/
|
||||
public getHardwareConcurrency(): number {
|
||||
return navigator.hardwareConcurrency || 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建Worker
|
||||
*/
|
||||
public createWorker(script: string, options: WorkerCreationOptions = {}): PlatformWorker {
|
||||
if (!this.isWorkerSupported()) {
|
||||
throw new Error('浏览器不支持Worker');
|
||||
}
|
||||
|
||||
try {
|
||||
return new BrowserWorker(script, options);
|
||||
} catch (error) {
|
||||
throw new Error(`创建浏览器Worker失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建SharedArrayBuffer
|
||||
*/
|
||||
public createSharedArrayBuffer(length: number): SharedArrayBuffer | null {
|
||||
if (!this.isSharedArrayBufferSupported()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new SharedArrayBuffer(length);
|
||||
} catch (error) {
|
||||
console.warn('SharedArrayBuffer创建失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取高精度时间戳
|
||||
*/
|
||||
public getHighResTimestamp(): number {
|
||||
return performance.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台配置
|
||||
*/
|
||||
public getPlatformConfig(): PlatformConfig {
|
||||
return {
|
||||
maxWorkerCount: this.getHardwareConcurrency(),
|
||||
supportsModuleWorker: false,
|
||||
supportsTransferableObjects: true,
|
||||
maxSharedArrayBufferSize: 1024 * 1024 * 1024, // 1GB
|
||||
workerScriptPrefix: '',
|
||||
limitations: {
|
||||
noEval: false,
|
||||
requiresWorkerInit: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取浏览器信息
|
||||
*/
|
||||
private getBrowserInfo(): string {
|
||||
const userAgent = navigator.userAgent;
|
||||
if (userAgent.includes('Chrome')) {
|
||||
const match = userAgent.match(/Chrome\/([0-9.]+)/);
|
||||
return match ? `Chrome ${match[1]}` : 'Chrome';
|
||||
} else if (userAgent.includes('Firefox')) {
|
||||
const match = userAgent.match(/Firefox\/([0-9.]+)/);
|
||||
return match ? `Firefox ${match[1]}` : 'Firefox';
|
||||
} else if (userAgent.includes('Safari')) {
|
||||
const match = userAgent.match(/Version\/([0-9.]+)/);
|
||||
return match ? `Safari ${match[1]}` : 'Safari';
|
||||
}
|
||||
return 'Unknown Browser';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查SharedArrayBuffer是否真正可用
|
||||
*/
|
||||
private checkSharedArrayBufferEnabled(): boolean {
|
||||
try {
|
||||
new SharedArrayBuffer(8);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器Worker封装
|
||||
*/
|
||||
class BrowserWorker implements PlatformWorker {
|
||||
private _state: 'running' | 'terminated' = 'running';
|
||||
private worker: Worker;
|
||||
|
||||
constructor(script: string, options: WorkerCreationOptions = {}) {
|
||||
this.worker = this.createBrowserWorker(script, options);
|
||||
}
|
||||
|
||||
public get state(): 'running' | 'terminated' {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
public postMessage(message: any, transfer?: Transferable[]): void {
|
||||
if (this._state === 'terminated') {
|
||||
throw new Error('Worker已被终止');
|
||||
}
|
||||
|
||||
try {
|
||||
if (transfer && transfer.length > 0) {
|
||||
this.worker.postMessage(message, transfer);
|
||||
} else {
|
||||
this.worker.postMessage(message);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`发送消息到Worker失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public onMessage(handler: (event: { data: any }) => void): void {
|
||||
this.worker.onmessage = (event: MessageEvent) => {
|
||||
handler({ data: event.data });
|
||||
};
|
||||
}
|
||||
|
||||
public onError(handler: (error: ErrorEvent) => void): void {
|
||||
this.worker.onerror = handler;
|
||||
}
|
||||
|
||||
public terminate(): void {
|
||||
if (this._state === 'running') {
|
||||
try {
|
||||
this.worker.terminate();
|
||||
this._state = 'terminated';
|
||||
} catch (error) {
|
||||
console.error('终止Worker失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建浏览器Worker
|
||||
*/
|
||||
private createBrowserWorker(script: string, options: WorkerCreationOptions): Worker {
|
||||
try {
|
||||
// 创建Blob URL
|
||||
const blob = new Blob([script], { type: 'application/javascript' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// 创建Worker
|
||||
const worker = new Worker(url, {
|
||||
type: options.type || 'classic',
|
||||
credentials: options.credentials,
|
||||
name: options.name
|
||||
});
|
||||
|
||||
// 清理Blob URL(延迟清理,确保Worker已加载)
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 1000);
|
||||
|
||||
return worker;
|
||||
} catch (error) {
|
||||
throw new Error(`无法创建浏览器Worker: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user