2025-10-08 20:52:31 +08:00
|
|
|
|
import { DemoBase, DemoInfo } from './DemoBase';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Component,
|
|
|
|
|
|
ECSComponent,
|
|
|
|
|
|
Entity,
|
|
|
|
|
|
EntitySystem,
|
2025-10-09 17:43:46 +08:00
|
|
|
|
Matcher,
|
2025-10-08 20:52:31 +08:00
|
|
|
|
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 {
|
2025-10-09 17:43:46 +08:00
|
|
|
|
constructor() {
|
|
|
|
|
|
super(Matcher.all(PositionComponent, VelocityComponent));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
protected override process(entities: readonly Entity[]): void {
|
2025-10-08 20:52:31 +08:00
|
|
|
|
for (const entity of entities) {
|
2025-10-09 17:43:46 +08:00
|
|
|
|
const [pos, vel] = this.getComponents(entity, PositionComponent, VelocityComponent);
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2025-10-08 20:52:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class RenderSystem extends EntitySystem {
|
|
|
|
|
|
private canvas: HTMLCanvasElement;
|
|
|
|
|
|
private ctx: CanvasRenderingContext2D;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(canvas: HTMLCanvasElement) {
|
2025-10-09 17:43:46 +08:00
|
|
|
|
super(Matcher.all(PositionComponent, RenderableComponent));
|
2025-10-08 20:52:31 +08:00
|
|
|
|
this.canvas = canvas;
|
|
|
|
|
|
this.ctx = canvas.getContext('2d')!;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-09 17:43:46 +08:00
|
|
|
|
protected override process(entities: readonly Entity[]): void {
|
2025-10-08 20:52:31 +08:00
|
|
|
|
// 清空画布
|
|
|
|
|
|
this.ctx.fillStyle = '#0a0a15';
|
|
|
|
|
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染所有实体
|
|
|
|
|
|
for (const entity of entities) {
|
2025-10-09 17:43:46 +08:00
|
|
|
|
const [pos, render] = this.getComponents(entity, PositionComponent, RenderableComponent);
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2025-10-08 20:52:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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会处理渲染
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|