一、什么是 ECS?
ECS 是一种在游戏开发中非常流行的架构模式,它的核心思想是组合优于继承,通过将实体的数据(Components)与逻辑(Systems)完全分离,来获得极高的灵活性、可重用性和性能。
我将分步创建一个清晰的、带有详尽中文注释的实现,并最后给出一个完整的运行示例。
二、 核心概念定义
ECS 的三个核心元素:
- 实体 (Entity): 仅仅是一个唯一的 ID,像一个空壳容器,用来关联一组组件。
- 组件 (Component) : 纯粹的数据载体,不包含任何逻辑。例如
PositionComponent
只存储x
和y
坐标。 - 系统 (System): 包含所有逻辑的地方。它会查询所有拥有特定组件组合的实体,并对它们的数据进行处理。
三. 代码示例
我们将创建以下几个文件来构建我们的迷你 ECS 框架:
Entity.ts
: 定义实体的类型。Component.ts
: 定义组件和一些具体的组件示例。System.ts
: 定义系统的抽象基类。ECSWorld.ts
: 整个 ECS 框架的核心管理器。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开发干货