ECS 系统的一种简单 TS 实现

Entity Component System(ECS)是一种数据驱动的架构模式,在游戏开发和需要处理大量动态对象的系统中广泛应用。它通过将传统面向对象的继承体系拆解为实体(Entity)组件(Component)系统(System) 三个核心概念,实现了高度的灵活性和可维护性。本文使用 TypeScript 实现一个功能完整的 ECS 系统。

ECS 的核心思想

在实现之前,让我们先理解 ECS 的三个核心特性:

1. 组合优于继承(Composition over Inheritance)

传统 OOP 通过继承构建对象层级:

java 复制代码
GameObject
├── Player extends GameObject
├── Enemy extends GameObject
│   ├── FlyingEnemy extends Enemy
│   └── BossEnemy extends Enemy
└── NPC extends GameObject

这种设计会导致:

  • 层级爆炸:需求变化时继承链快速膨胀
  • 功能重复:Flying 能力无法在 Player 和 Enemy 间复用
  • 耦合严重:修改基类影响所有子类

ECS 通过组合解决这个问题:

typescript 复制代码
// 灵活组合,无继承链
player = Entity + Position + Velocity + Health + PlayerInput;
enemy = Entity + Position + Velocity + Health + AI;
flyingEnemy = Entity + Position + Velocity + Health + AI + Flying;
flyingNPC = Entity + Position + Velocity + Flying + Dialogue;

2. 数据与逻辑分离(Data and Logic Separation)

  • Component:纯数据结构,不包含任何逻辑
  • System:纯逻辑处理,对拥有特定组件的实体执行操作
  • Entity:仅仅是 ID,作为组件的容器

3. 查询驱动(Query-Based Processing)

System 声明所需的组件类型,World 自动筛选出匹配的实体:

typescript 复制代码
class MovementSystem {
  requiredComponents = ["Position", "Velocity"];

  update(entities) {
    // 只处理同时拥有 Position 和 Velocity 的实体
  }
}

使用 TypeScript 实现 ECS

理解了 ECS 的设计思想后,让我们动手实现一个功能完整的 ECS 系统。我们将采用渐进式的方式,从基础类型定义开始,逐步构建出包含实体管理、组件存储、系统查询等特性的完整系统。

1:定义基础类型

首先,我们需要定义 ECS 系统中的基础数据结构:实体、组件和系统。

typescript 复制代码
type EntityId = number;
type ComponentType = string;

// Component 是纯数据
interface Component {
  readonly type: ComponentType;
}

// System 处理具有特定组件的实体
interface System {
  readonly requiredComponents: Set<ComponentType>;
  update(entities: EntityId[], world: World, deltaTime: number): void;
}

关键点

  • EntityId 只是一个数字,实体本身没有数据和行为
  • Component 要求有 type 字段标识组件类型,其他字段由具体组件定义
  • System 声明所需组件集合,World 负责筛选匹配的实体
  • deltaTime 用于时间相关的计算(如物理模拟)

2:实体与组件存储设计

有了基础类型定义后,我们需要考虑如何存储实体和组件。这里的核心挑战是:如何高效地查询拥有特定组件组合的实体。

typescript 复制代码
export class World {
  private nextEntityId = 1;
  private entities = new Set<EntityId>();
  private components = new Map<EntityId, Map<ComponentType, Component>>();
  private systems: System[] = [];
}

数据结构说明

typescript 复制代码
// 实体存储
entities: Set<EntityId>
// O(1) 存在性检查,O(n) 遍历

// 组件存储(二级映射)
components: Map<EntityId, Map<ComponentType, Component>>
// 外层:实体ID → 该实体的所有组件
// 内层:组件类型 → 组件数据

// 系统存储
systems: System[]
// 按注册顺序执行

关键点

  • 使用 Set 存储实体 ID,快速检查实体是否存在
  • 使用 二级 Map 存储组件:第一层按实体索引,第二层按组件类型索引
  • 这种结构使得单个实体的组件增删改查都是 O(1)
  • 查询操作需要遍历所有实体,是 O(n)(可以通过 Archetype 优化)

3:实体生命周期管理

现在实现实体的创建和删除。实体的生命周期非常简单:创建时分配 ID,删除时清理所有相关数据。

typescript 复制代码
// 创建新实体
createEntity(): EntityId {
  const id = this.nextEntityId++;
  this.entities.add(id);
  this.components.set(id, new Map());
  return id;
}

// 删除实体及其所有组件
removeEntity(id: EntityId): void {
  this.entities.delete(id);
  this.components.delete(id);
}

关键点

  • 实体 ID 使用递增计数器生成,保证唯一性
  • 创建实体时同时初始化空的组件 Map
  • 删除实体时自动删除所有关联的组件
  • 删除操作是级联的,不会留下孤儿组件

4:组件管理

接下来实现组件的增删改查。组件是 ECS 的核心数据载体,这些操作需要简洁高效。

typescript 复制代码
// 为实体添加组件
addComponent(entityId: EntityId, component: Component): void {
  const entityComponents = this.components.get(entityId);
  if (entityComponents) {
    entityComponents.set(component.type, component);
  }
}

// 移除实体的组件
removeComponent(entityId: EntityId, componentType: ComponentType): void {
  this.components.get(entityId)?.delete(componentType);
}

// 获取实体的组件
getComponent<T extends Component>(entityId: EntityId, componentType: ComponentType): T | undefined {
  return this.components.get(entityId)?.get(componentType) as T | undefined;
}

关键点

  • 使用 可选链操作符?.)简化空值检查
  • getComponent 使用泛型 <T> 提供类型提示
  • 组件通过 type 字段区分,支持同类型组件的替换
  • 如果实体不存在,操作会静默失败(返回 undefined

5:组件查询机制

查询是 ECS 的核心功能之一。我们需要能够找出所有拥有特定组件组合的实体。

typescript 复制代码
// 检查实体是否拥有所有指定组件
hasComponents(entityId: EntityId, componentTypes: Set<ComponentType>): boolean {
  const entityComponents = this.components.get(entityId);
  if (!entityComponents) return false;

  for (const type of componentTypes) {
    if (!entityComponents.has(type)) return false;
  }
  return true;
}

// 查询拥有特定组件的实体
query(componentTypes: ComponentType[]): EntityId[] {
  const typeSet = new Set(componentTypes);
  return Array.from(this.entities).filter(id => this.hasComponents(id, typeSet));
}

查询流程

  1. 遍历所有实体 ID
  2. 对每个实体检查是否拥有所需的所有组件
  3. 返回匹配的实体列表

关键点

  • 将数组转换为 Set 提高查询效率(has 是 O(1))
  • 使用 filter 进行声明式查询
  • 时间复杂度:O(n × m),n 是实体数量,m 是查询的组件数量
  • 优化空间:可以引入 Archetype 将查询优化到 O(1)

6:系统注册与更新

最后实现系统的注册和执行。系统是 ECS 中的逻辑处理单元,负责对匹配的实体执行操作。

typescript 复制代码
// 注册系统
addSystem(system: System): void {
  this.systems.push(system);
}

// 更新所有系统
update(deltaTime: number): void {
  for (const system of this.systems) {
    const matchingEntities = Array.from(this.entities)
      .filter(id => this.hasComponents(id, system.requiredComponents));

    system.update(matchingEntities, this, deltaTime);
  }
}

执行流程

  1. 遍历所有注册的系统
  2. 对每个系统,查询匹配 requiredComponents 的实体
  3. 调用系统的 update 方法,传入匹配的实体列表

关键点

  • 系统按注册顺序执行,顺序很重要(如:移动系统 → 碰撞检测系统)
  • 每个系统在每帧都会重新查询,支持动态添加/删除组件
  • 系统可以通过 world 参数访问和修改实体的组件
  • 优化空间:可以缓存查询结果,在组件变化时才重新查询

完整代码

typescript 复制代码
type EntityId = number;
type ComponentType = string;

interface Component {
  readonly type: ComponentType;
}

interface System {
  readonly requiredComponents: Set<ComponentType>;
  update(entities: EntityId[], world: World, deltaTime: number): void;
}

export class World {
  private nextEntityId = 1;
  private entities = new Set<EntityId>();
  private components = new Map<EntityId, Map<ComponentType, Component>>();
  private systems: System[] = [];

  createEntity(): EntityId {
    const id = this.nextEntityId++;
    this.entities.add(id);
    this.components.set(id, new Map());
    return id;
  }

  removeEntity(id: EntityId): void {
    this.entities.delete(id);
    this.components.delete(id);
  }

  addComponent(entityId: EntityId, component: Component): void {
    const entityComponents = this.components.get(entityId);
    if (entityComponents) {
      entityComponents.set(component.type, component);
    }
  }

  removeComponent(entityId: EntityId, componentType: ComponentType): void {
    this.components.get(entityId)?.delete(componentType);
  }

  getComponent<T extends Component>(
    entityId: EntityId,
    componentType: ComponentType
  ): T | undefined {
    return this.components.get(entityId)?.get(componentType) as T | undefined;
  }

  hasComponents(
    entityId: EntityId,
    componentTypes: Set<ComponentType>
  ): boolean {
    const entityComponents = this.components.get(entityId);
    if (!entityComponents) return false;

    for (const type of componentTypes) {
      if (!entityComponents.has(type)) return false;
    }
    return true;
  }

  addSystem(system: System): void {
    this.systems.push(system);
  }

  query(componentTypes: ComponentType[]): EntityId[] {
    const typeSet = new Set(componentTypes);
    return Array.from(this.entities).filter((id) =>
      this.hasComponents(id, typeSet)
    );
  }

  update(deltaTime: number): void {
    for (const system of this.systems) {
      const matchingEntities = Array.from(this.entities).filter((id) =>
        this.hasComponents(id, system.requiredComponents)
      );

      system.update(matchingEntities, this, deltaTime);
    }
  }
}

export type { EntityId, Component, ComponentType, System };

至此,我们已经实现了一个功能完整的 ECS 系统,核心代码只有约 90 行。

使用示例

让我们通过几个实际例子来看看它是如何工作的:

基础使用

typescript 复制代码
import { World } from "./ecs.ts";

// 定义组件类型
interface Position extends Component {
  type: "Position";
  x: number;
  y: number;
}

interface Velocity extends Component {
  type: "Velocity";
  dx: number;
  dy: number;
}

// 创建世界
const world = new World();

// 创建实体
const player = world.createEntity();

// 添加组件
world.addComponent(player, { type: "Position", x: 0, y: 0 });
world.addComponent(player, { type: "Velocity", dx: 10, dy: 5 });

// 查询拥有特定组件的实体
const movableEntities = world.query(["Position", "Velocity"]);
console.log(movableEntities); // [1]

实现 System

typescript 复制代码
// 移动系统:处理所有有位置和速度的实体
class MovementSystem implements System {
  requiredComponents = new Set(["Position", "Velocity"]);

  update(entities: EntityId[], world: World, deltaTime: number): void {
    for (const entityId of entities) {
      const pos = world.getComponent<Position>(entityId, "Position");
      const vel = world.getComponent<Velocity>(entityId, "Velocity");

      if (pos && vel) {
        pos.x += vel.dx * deltaTime;
        pos.y += vel.dy * deltaTime;
      }
    }
  }
}

// 注册并运行系统
world.addSystem(new MovementSystem());

// 每帧更新
setInterval(() => {
  world.update(1 / 60); // 60 FPS
}, 16);

多系统协同

typescript 复制代码
interface Health extends Component {
  type: "Health";
  value: number;
}

// 伤害系统:减少生命值
class DamageSystem implements System {
  requiredComponents = new Set(["Health"]);
  damagePerSecond = 10;

  update(entities: EntityId[], world: World, deltaTime: number): void {
    for (const entityId of entities) {
      const health = world.getComponent<Health>(entityId, "Health");

      if (health) {
        health.value -= this.damagePerSecond * deltaTime;

        // 如果生命值小于0,移除实体
        if (health.value <= 0) {
          world.removeEntity(entityId);
        }
      }
    }
  }
}

// 注册多个系统
world.addSystem(new MovementSystem());
world.addSystem(new DamageSystem());

// 创建带生命值的移动实体
const enemy = world.createEntity();
world.addComponent(enemy, { type: "Position", x: 100, y: 100 });
world.addComponent(enemy, { type: "Velocity", dx: -5, dy: 0 });
world.addComponent(enemy, { type: "Health", value: 20 });

// 更新 2 秒后,enemy 的生命值归零,会被自动删除
world.update(1);
world.update(1);

const stillExists = world.query(["Health"]);
console.log(stillExists.length); // 0

动态组件组合

typescript 复制代码
interface Flying extends Component {
  type: "Flying";
  altitude: number;
}

// 创建不同类型的实体
const groundEnemy = world.createEntity();
world.addComponent(groundEnemy, { type: "Position", x: 0, y: 0 });
world.addComponent(groundEnemy, { type: "Velocity", dx: 5, dy: 0 });

const flyingEnemy = world.createEntity();
world.addComponent(flyingEnemy, { type: "Position", x: 0, y: 0 });
world.addComponent(flyingEnemy, { type: "Velocity", dx: 5, dy: 0 });
world.addComponent(flyingEnemy, { type: "Flying", altitude: 100 });

// 飞行系统只处理会飞的实体
class FlyingSystem implements System {
  requiredComponents = new Set(["Flying", "Position"]);

  update(entities: EntityId[], world: World): void {
    for (const entityId of entities) {
      const flying = world.getComponent<Flying>(entityId, "Flying");
      console.log(
        `Entity ${entityId} is flying at altitude ${flying?.altitude}`
      );
    }
  }
}

world.addSystem(new FlyingSystem());
world.update(0); // 只有 flyingEnemy 会被处理

ECS 的优势

1. 灵活性

添加新功能只需:

  1. 定义新组件(数据)
  2. 创建新系统(逻辑)
  3. 组合到实体上

无需修改现有代码,符合开闭原则

2. 可复用性

组件和系统都是独立的,可以自由组合:

typescript 复制代码
// 复用 Flying 组件
player = Entity + Position + Velocity + Flying + PlayerInput;
platform = Entity + Position + Flying + Obstacle;
powerup = Entity + Position + Flying + Collectable;

3. 性能潜力

  • 数据局部性:组件可以按类型连续存储(本实现未优化)
  • 并行处理:不相交的系统可以并行执行
  • 缓存友好:遍历单一类型的组件数据,CPU 缓存命中率高

4. 可测试性

  • 组件是纯数据,易于序列化和 mock
  • 系统是纯逻辑,可以独立测试
  • 不依赖复杂的继承关系

5. 序列化能力

组件的纯数据特性使得 ECS 天然支持序列化:

typescript 复制代码
// 导出世界状态
const snapshot = world.serialize();
// 返回 JSON 格式: { entities: [...], components: {...} }

// 恢复世界状态
const newWorld = World.deserialize(snapshot);

应用场景:

  • 存档系统:保存和加载游戏进度
  • 网络同步:多人游戏状态同步
  • 回放功能:记录和重放游戏过程
  • 调试工具:导出状态用于分析
  • 热重载:开发时保持状态重载代码
相关推荐
shenshizhong3 小时前
鸿蒙HDF框架源码分析
前端·源码·harmonyos
凌晨起床3 小时前
Vue3 对比 Vue2
前端·javascript
clausliang3 小时前
实现一个可插入变量的文本框
前端·vue.js
yyongsheng3 小时前
SpringBoot项目集成easy-es框架
java·服务器·前端
fruge3 小时前
前端工程化流程搭建与配置优化指南
前端
东芃93943 小时前
uniapp上传blob对象到后台
前端·javascript·uni-app
coding随想4 小时前
救命!网页还在偷偷耗电?浏览器Battery API事件教你精准控电,这5个场景用了都说香
前端
IT_陈寒4 小时前
Redis性能翻倍的5个冷门优化技巧,90%的开发者都不知道第3个!
前端·人工智能·后端
华仔啊5 小时前
无需UI库!50行CSS打造丝滑弹性动效导航栏,拿来即用
前端·css