带你用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开发干货

相关推荐
掘了14 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅14 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅15 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅15 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment15 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅15 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊15 小时前
jwt介绍
前端
爱敲代码的小鱼16 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
Cobyte16 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc
NEXT0616 小时前
前端算法:从 O(n²) 到 O(n),列表转树的极致优化
前端·数据结构·算法