带你用TS彻底搞懂ECS架构模式

一、什么是 ECS?

ECS 是一种在游戏开发中非常流行的架构模式,它的核心思想是组合优于继承,通过将实体的数据(Components)与逻辑(Systems)完全分离,来获得极高的灵活性、可重用性和性能。

我将分步创建一个清晰的、带有详尽中文注释的实现,并最后给出一个完整的运行示例。

二、 核心概念定义

ECS 的三个核心元素:

  • 实体 (Entity): 仅仅是一个唯一的 ID,像一个空壳容器,用来关联一组组件。
  • 组件 (Component) : 纯粹的数据载体,不包含任何逻辑。例如 PositionComponent 只存储 xy 坐标。
  • 系统 (System): 包含所有逻辑的地方。它会查询所有拥有特定组件组合的实体,并对它们的数据进行处理。

三. 代码示例

我们将创建以下几个文件来构建我们的迷你 ECS 框架:

  1. Entity.ts: 定义实体的类型。
  2. Component.ts: 定义组件和一些具体的组件示例。
  3. System.ts: 定义系统的抽象基类。
  4. ECSWorld.ts: 整个 ECS 框架的核心管理器。
  5. main.ts: 使用我们框架的完整示例。

Entity.ts

在 ECS 模式中,实体(Entity)仅仅是一个唯一的标识符,通常是一个数字。它本身不包含任何数据或逻辑。实体通过附加不同的组件来获得数据和行为。

typescript 复制代码
export type Entity = number;

Component.ts

组件是纯粹的数据容器。它只包含数据,不应该包含任何游戏逻辑。例如,位置、速度、生命值等都可以作为组件。

typescript 复制代码
// 定义一个通用的组件类构造函数类型,方便在系统中进行类型约束
export type ComponentClass<T extends object> = new (...args: any[]) => T;

具体示例组件

typescript 复制代码
// VelocityComponent.ts
/**
 * 速度组件
 * 存储实体在二维空间中的速度矢量。
 */
export class VelocityComponent {
    constructor(public dx: number = 0, public dy: number = 0) {}
}

// RenderableComponent.ts
/**
 * 可渲染组件
 * 存储用于在屏幕上显示实体的信息,这里简化为一个字符。
 */
export class RenderableComponent {
    constructor(public sprite: string = '') {}
}

// PositionComponent.ts
/**
 * 位置组件
 * 存储实体在二维空间中的坐标。
 */
export class PositionComponent {
    constructor(public x: number = 0, public y: number = 0) {}
}

// HpComponent.ts
/**
 * hp组件
 * 存储实体的hp
 */
export class HpComponent {
    constructor(public current: number = 100, public max: number = 100) {}
}

System.ts

系统包含所有的逻辑。它会遍历所有拥有特定组件组合的实体,并对这些组件的数据进行操作。

typescript 复制代码
import { Entity } from './Entity';
import { ComponentClass } from './Component';
import { ECSWorld } from './ECSWorld';

export abstract class System {
    /**
     * 构造函数
     * @param world 对 ECSWorld 的引用,让系统可以访问和操作组件数据。
     */
    constructor(protected world: ECSWorld) {}

    /**
     * 这个系统所关注的实体必须拥有的组件类型列表。
     * 子类必须实现这个属性。我们使用 Set 来优化查询性能。
     * 例如: `new Set([PositionComponent, VelocityComponent])`
     */
    public abstract readonly requiredComponents: Set<ComponentClass<any>>;

    /**
     * 系统的主更新方法,每一帧(或每一次游戏循环)被调用。
     * @param entities 一个包含所有符合 `requiredComponents` 要求的实体的集合。
     * @param dt 自上一帧以来的时间差(delta time),用于实现与帧率无关的更新。
     */
    public abstract update(entities: Set<Entity>, dt: number): void;
}

具体示例系统

typescript 复制代码
// MovementSystem.ts
import { ComponentClass } from "../Component/Component";
import { System } from "./System";
import { PositionComponent } from "../Component/PositionComponent";
import { VelocityComponent } from "../Component/VelocityComponent";
import { Entity } from "../Entity/Entity";

/**
 * 移动系统(MovementSystem)
 * 负责更新所有拥有 PositionComponent 和 VelocityComponent 的实体的位置。
 */
export class MovementSystem extends System {
    // 定义本系统关注的组件
    public readonly requiredComponents: Set<ComponentClass<any>> = new Set([PositionComponent, VelocityComponent]);

    public update(entities: Set<Entity>, dt: number): void {
        console.log('--- 移动系统正在更新 ---');
        for (const entity of entities) {
            // 从 world 中获取实体所需的组件
            // 使用 "!" 非空断言,因为 ECSWorld 保证了传递给 update 的实体一定拥有这些组件
            const pos = this.world.getComponent(entity, PositionComponent)!;
            const vel = this.world.getComponent(entity, VelocityComponent)!;

            // 核心逻辑:更新位置
            pos.x += vel.dx * dt;
            pos.y += vel.dy * dt;

            console.log(`  实体 ${entity} 移动到 (${pos.x.toFixed(2)}, ${pos.y.toFixed(2)})`);
        }
    }
}

// RenderSystem.ts
import { ComponentClass } from "../Component/Component";
import { System } from "./System";
import { PositionComponent } from "../Component/PositionComponent";
import { RenderableComponent } from "../Component/RenderableComponent";
import { Entity } from "../Entity/Entity";

/**
 * 渲染系统(RenderSystem)
 * 负责"绘制"所有拥有 PositionComponent 和 RenderableComponent 的实体。
 */
export class RenderSystem extends System {
    public readonly requiredComponents: Set<ComponentClass<any>> = new Set([PositionComponent, RenderableComponent]);

    public update(entities: Set<Entity>, dt: number): void {
        console.log('--- 渲染系统正在更新 ---');
        for (const entity of entities) {
            const pos = this.world.getComponent(entity, PositionComponent)!;
            const render = this.world.getComponent(entity, RenderableComponent)!;

            // 核心逻辑:在控制台打印实体信息
            console.log(`  在位置 (${pos.x.toFixed(2)}, ${pos.y.toFixed(2)}) 渲染出 ${render.sprite}`);
        }
    }
}

// DamageSystem.ts
import { ComponentClass } from "../Component/Component";
import { System } from "./System";
import { HpComponent } from "../Component/HpComponent";
import { Entity } from "../Entity/Entity";

/**
 * 伤害系统(DamageSystem)
 * 模拟一个简单的战斗逻辑,让所有实体每帧都受到一点伤害
 */
export class DamageSystem extends System {
    public readonly requiredComponents: Set<ComponentClass<any>> = new Set([HpComponent]);

    public update(entities: Set<Entity>, dt: number): void {
        console.log('--- 伤害系统正在更新 ---');
        for (const entity of entities) {
            const hp = this.world.getComponent(entity, HpComponent)!;

            // 核心逻辑:每秒掉 10 点血
            hp.current -= 10 * dt;
            console.log(`  实体 ${entity} 受到伤害,当前血量: ${hp.current.toFixed(0)}`);

            if (hp.current <= 0) {
                console.log(`  实体 ${entity} 已被摧毁!`);
                this.world.destroyEntity(entity);
            }
        }
    }
}

ECSWorld.ts

这是整个框架的核心, 管理实体(Entity)、组件(Component)和系统(System),负责创建和销毁实体,添加和移除组件,以及更新所有注册的系统。

typescript 复制代码
import { Entity } from './Entity/Entity';
import { System } from './System/System';
import { ComponentClass } from './Component/Component';
/**
 * @fileoverview 定义 ECSWorld 类,作为 ECS 框架的核心
 *
 * ECSWorld 管理实体(Entity)、组件(Component)和系统(System)。
 * 它负责创建和销毁实体,添加和移除组件,以及更新所有注册的系统。
 */
export class ECSWorld {
    // --- 属性 ---
    private nextEntityId: Entity = 0; // 用于生成唯一的实体 ID
    private entities: Set<Entity> = new Set(); // 存储当前世界中所有存活的实体

    // 核心数据结构:组件存储区
    // 外层 Map 的键是组件的构造函数(例如 PositionComponent),值是内层 Map。
    // 内层 Map 的键是实体 ID,值是该实体对应的组件实例。
    private componentStores: Map<ComponentClass<any>, Map<Entity, any>> = new Map();

    // 系统列表
    private systems: System[] = [];

    // --- 实体管理 ---

    /**
     * 创建一个新的实体。
     * @returns 返回新创建的实体的唯一 ID。
     */
    public createEntity(): Entity {
        const entity = this.nextEntityId++;
        this.entities.add(entity);
        return entity;
    }

    /**
     * 销毁一个实体及其所有关联的组件。
     * @param entity 要销毁的实体的 ID。
     */
    public destroyEntity(entity: Entity): void {
        if (!this.entities.has(entity)) {
            return; // 如果实体不存在,则不执行任何操作
        }

        // 从所有组件存储中移除该实体的组件
        for (const store of this.componentStores.values()) {
            store.delete(entity);
        }

        // 从实体集合中移除该实体
        this.entities.delete(entity);
    }

    // --- 组件管理 ---

    /**
     * 为一个实体添加一个组件。
     * @param entity 实体 ID。
     * @param component 要添加的组件实例。
     */
    public addComponent<T extends object>(entity: Entity, component: T): void {
        const componentClass = component.constructor as ComponentClass<T>;

        // 如果该类型的组件存储区还不存在,则创建一个
        if (!this.componentStores.has(componentClass)) {
            this.componentStores.set(componentClass, new Map());
        }

        // 将组件实例存入对应的存储区
        const store = this.componentStores.get(componentClass)!; // ! 断言 store 必定存在
        store.set(entity, component);
    }

    /**
     * 从一个实体上移除一个组件。
     * @param entity 实体 ID。
     * @param componentClass 要移除的组件的类(构造函数)。
     */
    public removeComponent<T extends object>(entity: Entity, componentClass: ComponentClass<T>): void {
        const store = this.componentStores.get(componentClass);
        if (store) {
            store.delete(entity);
        }
    }

    /**
     * 获取一个实体上的特定组件。
     * @param entity 实体 ID。
     * @param componentClass 要获取的组件的类。
     * @returns 返回组件实例,如果不存在则返回 undefined。
     */
    public getComponent<T extends object>(entity: Entity, componentClass: ComponentClass<T>): T | undefined {
        const store = this.componentStores.get(componentClass);
        return store ? store.get(entity) : undefined;
    }

    /**
     * 检查一个实体是否拥有特定组件。
     * @param entity 实体 ID。
     * @param componentClass 要检查的组件的类。
     * @returns 如果拥有则返回 true,否则返回 false。
     */
    public hasComponent<T extends object>(entity: Entity, componentClass: ComponentClass<T>): boolean {
        const store = this.componentStores.get(componentClass);
        return store ? store.has(entity) : false;
    }

    // --- 系统管理 ---

    /**
     * 向世界中注册一个系统。
     * @param system 要添加的系统实例。
     */
    public addSystem(system: System): void {
        this.systems.push(system);
    }

    /**
     * 更新所有系统,这是游戏循环的核心。
     * @param dt 距离上一帧的时间差(delta time)。
     */
    public update(dt: number): void {
        // 遍历每一个系统
        for (const system of this.systems) {
            // 为当前系统筛选出它感兴趣的实体集合
            const relevantEntities = new Set<Entity>();

            // 遍历世界中的所有实体
            for (const entity of this.entities) {
                let isRelevant = true;
                // 检查该实体是否拥有系统所需的所有组件
                for (const componentClass of system.requiredComponents) {
                    if (!this.hasComponent(entity, componentClass)) {
                        isRelevant = false;
                        break; // 只要缺少一个组件,就立即判断为不相关
                    }
                }

                if (isRelevant) {
                    relevantEntities.add(entity);
                }
            }

            // 如果找到了相关的实体,就调用系统的 update 方法
            if (relevantEntities.size > 0) {
                system.update(relevantEntities, dt);
            }
        }
    }
}

main.ts

现在,我们来使用上面创建的框架,实现一个简单的模拟场景。

typescript 复制代码
/**
 * @fileoverview 完整的 ECS 框架使用示例
 */

import { ECSWorld } from './ECSWorld';
import { HpComponent } from './Component/HpComponent';    // 示例:生命值组件
import { RenderableComponent } from './Component/RenderableComponent';    // 示例:可渲染组件
import { PositionComponent } from './Component/PositionComponent';    // 示例:位置组件
import { VelocityComponent } from './Component/VelocityComponent';    // 示例:速度组件


import { MovementSystem } from './System/MovementSystem'; // 示例:移动系统
import { RenderSystem } from './System/RenderSystem';     // 示例:渲染系统
import { DamageSystem } from './System/DamageSystem';     // 示例:伤害系统
// --- 2. 主函数:设置和运行 ECS 世界 ---

function main() {
    console.log('=== ECS 示例开始 ===');

    // 创建 ECS 世界实例
    const world = new ECSWorld();

    // --- 创建实体和组件 ---
    // 玩家实体:会移动、会渲染、有生命值
    const player = world.createEntity();
    world.addComponent(player, new PositionComponent(10, 20));
    world.addComponent(player, new VelocityComponent(5, 2)); // 速度较快
    world.addComponent(player, new RenderableComponent('👨‍🚀'));
    world.addComponent(player, new HpComponent(150, 150)); // 血量较多

    // 敌人A实体:会移动、会渲染、有生命值
    const enemyA = world.createEntity();
    world.addComponent(enemyA, new PositionComponent(100, 50));
    world.addComponent(enemyA, new VelocityComponent(-3, -1)); // 速度较慢
    world.addComponent(enemyA, new RenderableComponent('👽'));
    world.addComponent(enemyA, new HpComponent(80, 80));

    // 静态场景物体(如一棵树):只会渲染,没有速度和生命
    const tree = world.createEntity();
    world.addComponent(tree, new PositionComponent(200, 150));
    world.addComponent(tree, new RenderableComponent('🌳'));

    console.log('\n--- 实体和组件设置完毕 ---');
    console.log(`创建了实体: Player(${player}), EnemyA(${enemyA}), Tree(${tree})`);

    // --- 注册系统 ---
    world.addSystem(new MovementSystem(world));
    world.addSystem(new RenderSystem(world));
    world.addSystem(new DamageSystem(world));

    console.log('--- 系统注册完毕 ---\n');

    // --- 模拟游戏循环 ---
    let frame = 0;
    const gameLoop = setInterval(() => {
        frame++;
        // 假设每帧过去 0.5 秒,方便观察变化
        const deltaTime = 0.5;

        console.log(`\n============== 游戏循环: 第 ${frame} 帧 (dt = ${deltaTime}s) ==============`);

        // 调用 world 的主更新函数
        world.update(deltaTime);

        if (frame >= 10) {
            console.log('\n=== 游戏循环结束 ===');
            clearInterval(gameLoop);
        }
    }, 500); // 每 500 毫秒执行一次
}

// 运行主函数
main();
/*
=== ECS 示例开始 ===

--- 实体和组件设置完毕 ---
创建了实体: Player(0), EnemyA(1), Tree(2)
--- 系统注册完毕 ---


============== 游戏循环: 第 1 帧 (dt = 0.5s) ==============
--- 移动系统正在更新 ---
  实体 0 移动到 (12.50, 21.00)
  实体 1 移动到 (98.50, 49.50)
--- 渲染系统正在更新 ---
  在位置 (12.50, 21.00) 渲染出 👨‍🚀
  在位置 (98.50, 49.50) 渲染出 👽
  在位置 (200.00, 150.00) 渲染出 🌳
--- 伤害系统正在更新 ---
  实体 0 受到伤害,当前血量: 145
  实体 1 受到伤害,当前血量: 75

============== 游戏循环: 第 2 帧 (dt = 0.5s) ==============
--- 移动系统正在更新 ---
  实体 0 移动到 (15.00, 22.00)
  实体 1 移动到 (97.00, 49.00)
--- 渲染系统正在更新 ---
  在位置 (15.00, 22.00) 渲染出 👨‍🚀
  在位置 (97.00, 49.00) 渲染出 👽
  在位置 (200.00, 150.00) 渲染出 🌳
--- 伤害系统正在更新 ---
  实体 0 受到伤害,当前血量: 140
  实体 1 受到伤害,当前血量: 70

============== 游戏循环: 第 3 帧 (dt = 0.5s) ==============
--- 移动系统正在更新 ---
  实体 0 移动到 (17.50, 23.00)
  实体 1 移动到 (95.50, 48.50)
--- 渲染系统正在更新 ---
  在位置 (17.50, 23.00) 渲染出 👨‍🚀
  在位置 (95.50, 48.50) 渲染出 👽
  在位置 (200.00, 150.00) 渲染出 🌳
--- 伤害系统正在更新 ---
  实体 0 受到伤害,当前血量: 135
  实体 1 受到伤害,当前血量: 65

============== 游戏循环: 第 4 帧 (dt = 0.5s) ==============
--- 移动系统正在更新 ---
  实体 0 移动到 (20.00, 24.00)
  实体 1 移动到 (94.00, 48.00)
--- 渲染系统正在更新 ---
  在位置 (20.00, 24.00) 渲染出 👨‍🚀
  在位置 (94.00, 48.00) 渲染出 👽
  在位置 (200.00, 150.00) 渲染出 🌳
--- 伤害系统正在更新 ---
  实体 0 受到伤害,当前血量: 130
  实体 1 受到伤害,当前血量: 60

============== 游戏循环: 第 5 帧 (dt = 0.5s) ==============
--- 移动系统正在更新 ---
  实体 0 移动到 (22.50, 25.00)
  实体 1 移动到 (92.50, 47.50)
--- 渲染系统正在更新 ---
  在位置 (22.50, 25.00) 渲染出 👨‍🚀
  在位置 (92.50, 47.50) 渲染出 👽
  在位置 (200.00, 150.00) 渲染出 🌳
--- 伤害系统正在更新 ---
  实体 0 受到伤害,当前血量: 125
  实体 1 受到伤害,当前血量: 55

============== 游戏循环: 第 6 帧 (dt = 0.5s) ==============
--- 移动系统正在更新 ---
  实体 0 移动到 (25.00, 26.00)
  实体 1 移动到 (91.00, 47.00)
--- 渲染系统正在更新 ---
  在位置 (25.00, 26.00) 渲染出 👨‍🚀
  在位置 (91.00, 47.00) 渲染出 👽
  在位置 (200.00, 150.00) 渲染出 🌳
--- 伤害系统正在更新 ---
  实体 0 受到伤害,当前血量: 120
  实体 1 受到伤害,当前血量: 50

============== 游戏循环: 第 7 帧 (dt = 0.5s) ==============
--- 移动系统正在更新 ---
  实体 0 移动到 (27.50, 27.00)
  实体 1 移动到 (89.50, 46.50)
--- 渲染系统正在更新 ---
  在位置 (27.50, 27.00) 渲染出 👨‍🚀
  在位置 (89.50, 46.50) 渲染出 👽
  在位置 (200.00, 150.00) 渲染出 🌳
--- 伤害系统正在更新 ---
  实体 0 受到伤害,当前血量: 115
  实体 1 受到伤害,当前血量: 45

============== 游戏循环: 第 8 帧 (dt = 0.5s) ==============
--- 移动系统正在更新 ---
  实体 0 移动到 (30.00, 28.00)
  实体 1 移动到 (88.00, 46.00)
--- 渲染系统正在更新 ---
  在位置 (30.00, 28.00) 渲染出 👨‍🚀
  在位置 (88.00, 46.00) 渲染出 👽
  在位置 (200.00, 150.00) 渲染出 🌳
--- 伤害系统正在更新 ---
  实体 0 受到伤害,当前血量: 110
  实体 1 受到伤害,当前血量: 40

============== 游戏循环: 第 9 帧 (dt = 0.5s) ==============
--- 移动系统正在更新 ---
  实体 0 移动到 (32.50, 29.00)
  实体 1 移动到 (86.50, 45.50)
--- 渲染系统正在更新 ---
  在位置 (32.50, 29.00) 渲染出 👨‍🚀
  在位置 (86.50, 45.50) 渲染出 👽
  在位置 (200.00, 150.00) 渲染出 🌳
--- 伤害系统正在更新 ---
  实体 0 受到伤害,当前血量: 105
  实体 1 受到伤害,当前血量: 35

============== 游戏循环: 第 10 帧 (dt = 0.5s) ==============
--- 移动系统正在更新 ---
  实体 0 移动到 (35.00, 30.00)
  实体 1 移动到 (85.00, 45.00)
--- 渲染系统正在更新 ---
  在位置 (35.00, 30.00) 渲染出 👨‍🚀
  在位置 (85.00, 45.00) 渲染出 👽
  在位置 (200.00, 150.00) 渲染出 🌳
--- 伤害系统正在更新 ---
  实体 0 受到伤害,当前血量: 100
  实体 1 受到伤害,当前血量: 30

=== 游戏循环结束 ===
*/

这个实现充分体现了 ECS 的强大之处:逻辑和数据的完全解耦,带来了无与伦比的灵活性和组合能力。

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript/TypeScript开发干货

相关推荐
小只笨笨狗~1 小时前
el-dialog宽度根据内容撑开
前端·vue.js·elementui
weixin_490354341 小时前
Vue设计与实现
前端·javascript·vue.js
GISer_Jing2 小时前
React过渡更新:优化渲染性能的秘密
javascript·react.js·ecmascript
wayhome在哪3 小时前
3 分钟上手!用 WebAssembly 优化前端图片处理性能(附完整代码)
javascript·性能优化·webassembly
卓码软件测评3 小时前
【第三方网站运行环境测试:服务器配置(如Nginx/Apache)的WEB安全测试重点】
运维·服务器·前端·网络协议·nginx·web安全·apache
龙在天3 小时前
前端不求人系列 之 一条命令自动部署项目
前端
开开心心就好3 小时前
PDF转长图工具,一键多页转图片
java·服务器·前端·数据库·人工智能·pdf·推荐算法
国家不保护废物3 小时前
10万条数据插入页面:从性能优化到虚拟列表的终极方案
前端·面试·性能优化
文心快码BaiduComate4 小时前
七夕,画个动态星空送给Ta
前端·后端·程序员