【教学】如何创建一个ecs框架适用于帧同步项目

前言

当谈论如何创建一个 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框架的设计我觉得大家可能了解的都比较多了,主要就是实体,组件和系统等对应的管理器的处理,包括性能优化,比如对查询结果的缓存等等。所以这里就不再阐述了。可以在项目的源码中进行查看。

我们这里主要是针对帧同步应该要做哪些事情先列一个清单:

  1. 状态同步:帧同步项目需要在客户端和服务器之间同步实体状态

  2. 输入处理:帧同步项目通常需要处理来自多个客户端的输入。

  3. 预测和回滚:为了减少网络延迟的影响,帧同步项目通常使用客户端预测和服务器回滚

  4. 插值:为了在客户端平滑地显示实体状态变化,要实现插值功能

  5. 事件系统:为了方便在系统之间传递消息和事件,要实现一个事件系统。用于处理和分发事件。

  6. 网络通信:为了在客户端和服务器之间进行通信,要实现网络通信功能

实现状态同步

  • 对当前游戏状态进行快照和根据快照信息还原游戏状态,设计createStateSnapshotupdateStateFromSnapshot方法。

实现起来也并不复杂,主要是调用其序列化和反序列化实体和组件的信息来达到快照的目的

        /**
         * 创建当前游戏状态的快照
         * @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;
        }

但是这里还可以继续优化,比如减少序列化和反序列化的开销:序列化和反序列化可能会导致性能开销。可以考虑使用更高效的序列化库,如 MessagePackprotobuf。这些库通常比 JSON 更快,同时还可以减少数据大小

后面还有关于输入处理,预测和回滚等稍微会继续介绍。如果你有什么好的想法的建议可以与我分享或讨论

如果你迫不及待的想了解后续可以查看我另一个的开源项目 g-framework 来了解具体操作和其他是如何实现的

项目地址

esengine/g-framework: G-Framework 是一个基于 TypeScript 编写的实体组件系统(ECS)框架 (github.com)

项目目前是刚创立的,可以用于学习和商用。

6赞

实现输入处理

我在框架里的做法是实现了一个输入的管理类 InputManager。主要功能包括:

  • 管理输入缓冲区

  • 处理输入事件

  • 存储输入历史记录

为了与其他游戏引擎和平台集成,使用适配器模式,让用户使用自定义的输入适配器,将引擎的输入事件转换成框架所需要的InputEvent。使用的示例如下:


class CustomInputAdapter extends gs.InputAdapter {
    constructor(inputManager: gs.InputManager) {
        super(inputManager);
    }

    public handleInputEvent(event: any): void {
        // 转换游戏引擎的输入事件为框架需要的 InputEvent
        const inputEvent: gs.InputEvent = {
            type: event.type, // 用户自定义的输入类型,如键盘按键或鼠标移动
            data: event.data // 输入事件的相关数据,例如按键的键码或鼠标的坐标

        };

        // 将转换后的 InputEvent 传递给 InputManager
        this.sendInputToManager(inputEvent);
    }

}

那sendInputToManager里是做了什么呢,它是由输入适配器实现的方法

        protected sendInputToManager(inputEvent: InputEvent): void {
            this.inputManager.sendInput(inputEvent);
        }

调用至InputManager当中的sendInput方法

        public sendInput(event: InputEvent): void {
            this.handleInput(event);
        }

        private handleInput(event: InputEvent) {
            this.inputBuffer.addEvent(event);
            // 将输入和当前帧编号存储在输入历史记录中
            this.inputHistory.push({ frameNumber: this.getCurrentFrameNumber(), input: event });

            // 触发输入事件监听器
            this.eventListeners.forEach(listener => listener(event));

            // 当输入历史记录数量超过阈值时,删除最旧的事件
            if (this.inputHistory.length > this.historySizeThreshold) {
                this.inputHistory.splice(0, this.inputHistory.length - this.historySizeThreshold);
            }
        }

InputManager里面就会将这次的操作记录在输入缓冲区当中,并且记录到历史记录当中,如果输入记录数量超过阈值(默认1000)它就会删除最开始加入的历史记录。

接下来,用户要实例化 InputManager 并将自定义的 InputAdapter 设置为其适配器。


// 创建 EntityManager 实例
const entityManager = new gs.EntityManager(/* 传入您的 ComponentManager 实例列表 */);

// 从 EntityManager 获取 InputManager
const inputManager = entityManager.getInputManager();

// 创建自定义输入适配器实例
const customInputAdapter = new CustomInputAdapter(inputManager);

// 将自定义输入适配器设置为 InputManager 的适配器
inputManager.setAdapter(customInputAdapter);

现在,输入事件将通过自定义 InputAdapter 处理,并自动传递给 InputManager。InputManager 将存储输入历史记录以供客户端进行预测和回滚。

而在真正游戏逻辑当中,我们在系统中使用输入缓冲区来处理游戏输入。输入缓冲区是一种数据结构,用于存储处理过的输入事件。在游戏的更新循环中,我们可以访问输入缓冲区并根据存储的事件更新游戏实体的状态。下面是一个简单的例子,展示了如何在游戏更新循环中使用输入缓冲区:


class InputSystem extends gs.System {

    update(entities: gs.Entity[]): void {
        const inputBuffer = this.entityManager.getInputManager().getInputBuffer();

        // 处理输入缓冲区中的事件
        while (inputBuffer.hasEvents()) {
            const inputEvent = inputBuffer.consumeEvent();

            // 遍历实体并根据输入事件更新它们
            for (const entity of entities) {
                this.applyInputToEntity(entity, inputEvent);
            }

        }

    }

    // 将输入事件应用到游戏实体
    private applyInputToEntity(entity: gs.Entity, inputEvent: gs.InputEvent): void {
        // 示例:如果实体具有Movable组件,则处理移动输入
        if (entity.hasComponent(Movable)) {

            const movable = entity.getComponent(Movable);

            switch (inputEvent.type) {
                case 'moveLeft':
                    movable.velocity.x = -1;
                    break;

                case 'moveRight':
                    movable.velocity.x = 1;
                    break;

                case 'jump':
                    if (movable.isOnGround) {
                        movable.velocity.y = -1;
                    }
                    break;

                default:
                    break;

            }

        }

        // 示例:如果实体具有Shooter组件,处理射击输入
        if (entity.hasComponent(Shooter)) {
            const shooter = entity.getComponent(Shooter);

            if (inputEvent.type === 'shoot') {
                shooter.shoot();
            }
        }

    }

}

在这个示例中,我们定义了 applyInputToEntity 方法,该方法根据输入事件更新游戏实体的状态。首先,我们检查实体是否具有 Movable 组件。如果实体具有该组件,我们根据输入事件的类型更新实体的速度。接下来,我们检查实体是否具有 Shooter 组件。如果实体具有该组件,我们在输入事件类型为 ‘shoot’ 时调用 shooter.shoot() 方法。

这样,我们就能根据输入缓冲区中的事件来更新游戏实体的状态。

1赞

关于预测和回滚

在之前我们已经实现了快照功能,这已经为我们打下了一定的基础。
而输入缓冲区和事件处理也有助于在预测阶段处理用户输入同时在回滚时重新应用先前的输入。而且我们也提供了跟踪游戏状态的历史记录有助于确定回滚的范围。

在这里我就只考虑了设计一个可插拔的同步策略,用于处理不同网络的同步方案。

首先,实现一个同步策略接口,它包含发送状态,接受状态和处理状态更新等。

module gs {
    export interface ISyncStrategy {
        sendState(state: any): void;
        receiveState(state: any): any;
        handleStateUpdate(deltaTime: number): void;
    }
}

接着就是多种同步策略的实现,我提供了一些常见的同步策略,比如:快照插值和状态压缩两种策略。
SnapshotInterpolationStrategyStateCompressionStrategy。下面我介绍一下这两种策略的内容。

快照插值

快照插值策略主要用于在客户端平滑地显示服务器发送过来的游戏状态。这种策略需要在客户端维护一个快照数据和插值渲染的队列,根据插值的时间和进度来渲染多个快照之间的中间状态

在 handleStateUpdate 方法中实现插值逻辑,以便在客户端正确显示帧之间的中间状态。此处我们需要一个 interpolateAndUpdateGameState 方法用于插值和更新游戏状态,这个方法通常由用户来决定该如何做,用户可以通过这样的方式来实现自定义插值逻辑:

syncStrategy.onInterpolation = (prevSnapshot, nextSnapshot, progress) => {
  // 用户实现自定义实体插值逻辑
  // 例如:根据实体的类型更新位置、旋转、缩放等属性
};

状态压缩

状态压缩策略主要用于减小发送过程中的网络负载,通过对游戏状态进行压缩,只传输相对上一个状态发生变化的部分来节省网络带宽

用户需要实现游戏状态的压缩和解压缩逻辑。可以使用自定义的压缩和解压缩方法,也可以利用现有的库(如LZ-string等)来实现这一目标


syncStrategy.onCompressState = (state) => {
  // 使用合适的压缩方法将游戏状态进行压缩,返回压缩后的状态
  // 例如:使用LZ-string库实现游戏状态压缩
  return LZString.compressToUTF16(JSON.stringify(state));

};

syncStrategy.onDecompressState = (compressedState) => {
  // 使用合适的解压缩方法将压缩状态恢复为原始游戏状态,返回解压缩后的状态
  // 例如:使用LZ-string 实现游戏状态解压缩
  return JSON.parse(LZString.decompressFromUTF16(compressedState));
};

同时可以提供发送和接受状态更新的逻辑


syncStrategy.onSendState = (compressedState) => {

  // 在这里执行发送压缩状态的逻辑,例如使用网络库将压缩后的游戏状态发送给服务器或其他客户端

};

syncStrategy.onReceiveState = (decompressedState) => {

  // 在这里执行接收状态后的逻辑,例如使用解压缩后的状态更新游戏状态

};

如果需要,还可以为实例提供处理状态更新时执行的自定义操作:


syncStrategy.handleStateUpdate = (state) => {

  // 在这里执行自定义操作,例如日志记录、错误检测等

};

同步策略管理器

为了方便管理我加入了一个同步策略管理器 SyncStrategyManager。用法如下:

// 根据项目需求选择同步策略,并实例化 SyncManager
const syncManager = new gs.SyncStrategyManager(new gs.SnapshotInterpolationStrategy());

// 使用 SyncManager 发送、接收和处理游戏状态更新
syncManager.sendState(gameState);
syncManager.receiveState(receivedState);
syncManager.handleStateUpdate(deltaTime);

// 根据需要,可以在运行时切换到另一个同步策略
syncManager.setStrategy(new StateCompressionStrategy());

客户端插值

为了在客户端提供平滑的视觉效果,实现插值技术。插值可以用于平滑地显示实体在不同状态之间的过渡,从而降低网络延迟带来的影响。

首先,我们定义一个接口 Interpolatable ,确保所有需要插值的组件都实现了所需的方法

interface Interpolatable {
    savePreviousState(): void;
    applyInterpolation(factor: number): void;
}

然后为组件添加插值支持,以TransformComponent为例子,实现该接口:

class TransformComponent extends Component implements Interpolatable {
    position: Vector2;
    rotation: number;

    previousPosition: Vector2;
    previousRotation: number;

    savePreviousState() {
        this.previousPosition = { ...this.position };
        this.previousRotation = this.rotation;
    }

    applyInterpolation(factor: number) {
        const interpolatedPosition = {
            x: lerp(this.previousPosition.x, this.position.x, factor),
            y: lerp(this.previousPosition.y, this.position.y, factor),
        };
        const interpolatedRotation = lerp(this.previousRotation, this.rotation, factor);

        // 应用插值后的状态
        this.setPosition(interpolatedPosition);
        this.setRotation(interpolatedRotation);
    }
}

然后我们就可以在游戏的循环更新地方应用插值,根据当前时间和状态更新时间调用applyInterpolation方法。

   // 计算插值因子
    const factor = (timestamp - lastUpdateTime) / timeBetweenUpdates;
    
    // 应用插值
    entityManager.applyInterpolation(factor);

然后我们可以看一下entityManager里是如何实现applyInterpolation方法的

        /**
         * 应用插值
         * @param factor 
         */
        public applyInterpolation(factor: number) {
            for (const entity of this.getEntities()) {
                for (const [componentType, manager] of this.componentManagers) {
                    const component = entity.getComponent(componentType);
                    if (component instanceof Component && 'savePreviousState' in component && 'applyInterpolation' in component) {
                        (component as Interpolatable).applyInterpolation(factor);
                    }
                }
            }
        }

实现也很简单,只是判断是否拥有该接口的组件,如果有,则调用其applyInterpolation方法,这样我们就只实现具体的插值逻辑,由框架来调度和管理插值过程

网络通信

为了在客户端和服务器之间进行通信,实现网络通信功能。我首先实现了一个网络适配器,用于将ECS框架与用户选择的通信协议相连接,通过实现这个接口,用户可以根据自己的需求定制通信协议,从而实现客户端与服务器之间的通信:

module gs {
    export interface NetworkAdapter {
        /**
         * 将输入数据发送到服务器
         * @param frameNumber 客户端帧编号
         * @param inputData 输入数据
         */
        sendInput(frameNumber: number, inputData: any): void;

        /**
         * 从服务器接收状态更新
         * @param callback 处理服务器状态更新的回调函数
         */
        onServerUpdate(callback: (serverState: any) => void): void;
    }
}

根据自己的通信协议实现NetworkAdapter接口。例如,假设我们有一个基于WebSocket的通信协议,可以这样实现:

class WebSocketNetworkAdapter implements gs.NetworkAdapter {
  private websocket: WebSocket;

  constructor(url: string) {
    this.websocket = new WebSocket(url);
  }

  sendInput(frameNumber: number, inputData: any): void {
    const message = {
      frameNumber,
      inputData,
    };
    this.websocket.send(JSON.stringify(message));
  }

  onServerUpdate(callback: (serverState: any) => void): void {
    this.websocket.addEventListener("message", (event) => {
      const serverState = JSON.parse(event.data);
      callback(serverState);
    });
  }
}

然后实现一个网络管理器

module gs {
    export class NetworkManager {
        private networkAdapter: NetworkAdapter | null = null;

        /**
         * 设置网络适配器
         * @param adapter 用户实现的NetworkAdapter接口
         */
        setNetworkAdapter(adapter: NetworkAdapter): void {
            this.networkAdapter = adapter;
        }

        /**
         * 获取网络适配器
         * @returns 
         */
        getNetworkAdpater(): NetworkAdapter | null {
            return this.networkAdapter;
        }
    }
}

将实现的网络适配器设置到EntityManager


const entityManager = new gs.EntityManager(componentManagers);

const websocketAdapter = new WebSocketNetworkAdapter("wss://example.com/game");

entityManager.getNetworkManager().setNetworkAdapter(websocketAdapter);

然后使用网络适配器发送输入和处理服务器更新,在游戏逻辑中,使用sendInput方法发送客户端的输入数据到服务器。同时,使用onServerUpdate方法处理从服务器接收到的状态更新。

// 注册输入事件处理逻辑
const inputAdapter = new MyInputAdapter(entityManager.getInputManager());
inputAdapter.handleInputEvent(/* 某些特定于引擎的事件 */);

// 将转换后的 InputEvent 添加到输入缓冲区中
entityManager.getInputManager().sendInput(inputEvent);

// 监听服务器更新
entityManager.getNetworkManager().getNetworkAdapter().onServerUpdate((serverState) => {
  // 更新游戏状态
});

至此,我们针对一个帧同步的轻量级游戏框架已经具备雏形,后续就是针对不同的需求添加更多的支持和模块来帮助游戏的开发,如果你有什么好的建议和疑问的地方我们可以共同讨论

4赞

插眼[date=2023-08-17 timezone=“Asia/Shanghai”]

很受启发 :100: