前言
当谈论如何创建一个 ECS 框架之前,我想先提一下我的一个过去的项目。我之前在 GitHub 上建立过一个名为 esengine/ecs-framework: 一套 ECS 框架,可用于 Egret/Laya/Cocos 及其他使用 TS/JS 语言的引擎 (github.com) 的 ECS 框架。这个框架包含了大量的代码,比如物理检测、tween、协程等,甚至还加入了比较复杂的行为树和 Astar 等。从创建项目到现在已经过去两年多,我提交了 440 次更新。我觉得这个框架已经越来越难维护了。并且随着加入群的人每次都在询问一些问题,比如这个框架是否可以用作帧同步或者作为服务端框架。从而让我认为,一个适合游戏的框架不如去制作一个适合特定解决方案的框架,例如解决帧同步的客户端框架。所以我今天想分享一下我的想法。
设计帧同步框架
考虑到语言的特性,想要做出一个高性能且保证其易用性高的框架不是一件容易的事情。我所做的第一件事情就是在不依赖其他框架的情况下,使用简明的api和明确的组件加上系统隔离的设计来降低上手难度,来设计核心的ecs部分。
我们所知道ECS框架主要核心是 Entity/Component/System 三大部分构成。实体的主要有一个Id,组件则是一个抽象类,用于让用户实现自己的逻辑组件,而系统也是一个抽象类,也可以让用户去继承实现自己的系统。关于ECS框架的设计我觉得大家可能了解的都比较多了,主要就是实体,组件和系统等对应的管理器的处理,包括性能优化,比如对查询结果的缓存等等。所以这里就不再阐述了。可以在项目的源码中进行查看。
我们这里主要是针对帧同步应该要做哪些事情先列一个清单:
-
状态同步:帧同步项目需要在客户端和服务器之间同步实体状态
-
输入处理:帧同步项目通常需要处理来自多个客户端的输入。
-
预测和回滚:为了减少网络延迟的影响,帧同步项目通常使用客户端预测和服务器回滚
-
插值:为了在客户端平滑地显示实体状态变化,要实现插值功能
-
事件系统:为了方便在系统之间传递消息和事件,要实现一个事件系统。用于处理和分发事件。
-
网络通信:为了在客户端和服务器之间进行通信,要实现网络通信功能
实现状态同步
- 对当前游戏状态进行快照和根据快照信息还原游戏状态,设计
createStateSnapshot和updateStateFromSnapshot方法。
实现起来也并不复杂,主要是调用其序列化和反序列化实体和组件的信息来达到快照的目的
/**
* 创建当前游戏状态的快照
* @returns
*/
public createStateSnapshot(): any {
const snapshot: any = {
entities: [],
};
for (const entity of this.getEntities()) {
snapshot.entities.push(entity.serialize());
}
return snapshot;
}
/**
* 使用给定的状态快照更新游戏状态
* @param stateSnapshot
*/
public updateStateFromSnapshot(stateSnapshot: any): void {
const newEntityMap: Map<number, Entity> = new Map();
for (const entityData of stateSnapshot.entities) {
const entityId = entityData.id;
let entity = this.getEntity(entityId);
if (!entity) {
entity = new Entity(entityId, this.componentManagers);
entity.onCreate();
}
entity.deserialize(entityData);
newEntityMap.set(entityId, entity);
}
this.entities = newEntityMap;
}
但是在某些情况下,可能不需要每次都发送完整的状态快照。可以考虑实现增量更新,只发送自上次快照以来发生变化的实体和组件数据。这可以减少网络传输的数据量,从而提高性能:
/**
* 创建增量状态快照
* @param lastSnapshotVersion 上一个快照的版本号
* @returns 返回一个包含实体增量数据的快照对象
*/
public createIncrementalStateSnapshot(lastSnapshotVersion: number): any {
const snapshot: any = {
entities: [],
};
for (const entity of this.getEntities()) {
const serializedEntity = entity.serializeIncremental(lastSnapshotVersion);
if (serializedEntity) {
snapshot.entities.push(serializedEntity);
}
}
return snapshot;
}
只同步必要的数据:在实际应用中,可能并不需要同步所有组件的数据。所以在每个组件中定义一个 shouldSerialize 方法,以便在序列化时仅包含需要同步的组件。
/**
* 增量序列化
* @param lastSnapshotVersion 上一次快照版本
* @returns 返回增量序列化后的实体对象,如果没有更新的组件,则返回null
*/
public serializeIncremental(lastSnapshotVersion: number): any | null {
let hasUpdatedComponents = false;
const serializedEntity = {
id: this.id,
components: {},
};
for (const [componentType, manager] of this.componentManagers) {
const component = manager.get(this.id) as Component;
if (component && component.shouldSerialize() && component.version > lastSnapshotVersion) {
serializedEntity.components[componentType.name] = component.serialize();
hasUpdatedComponents = true;
}
}
return hasUpdatedComponents ? serializedEntity : null;
}
但是这里还可以继续优化,比如减少序列化和反序列化的开销:序列化和反序列化可能会导致性能开销。可以考虑使用更高效的序列化库,如 MessagePack 或 protobuf。这些库通常比 JSON 更快,同时还可以减少数据大小
后面还有关于输入处理,预测和回滚等稍微会继续介绍。如果你有什么好的想法的建议可以与我分享或讨论
如果你迫不及待的想了解后续可以查看我另一个的开源项目 g-framework 来了解具体操作和其他是如何实现的
项目地址
esengine/g-framework: G-Framework 是一个基于 TypeScript 编写的实体组件系统(ECS)框架 (github.com)
项目目前是刚创立的,可以用于学习和商用。
