关于 ecs
ecs 是使用 EliCS Entity Component System 框架构建 TypeScript 游戏的技能。它介绍实体、组件和系统的组织方式,并涵盖生命周期、查询及状态到视图的同步。
主要功能
- 01创建 EliCS 世界与实体
- 02采用仅存储数据的组件
- 03初始化并更新系统
- 04按组件查询实体
- 05暂停和恢复系统
- 06输入、状态与视图分层
适用场景
- 01组织具有共同行为的游戏对象
- 02安排每帧游戏状态更新
- 03在菜单或对话期间暂停系统
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ drawcall-ai/ skills --skill ecs安装后在 Claude Code 中输入/ecs确认已加载。
详细介绍
实体组件系统
我们使用 EliCS,这是一个轻量、高性能的 TypeScript 实体组件系统框架。它采用面向数据的设计,优先使用组合而非继承,并通过高效的位掩码和类型化数组存储数据。
**核心概念:**实体(唯一对象)、组件(数据容器)、系统(逻辑处理器)、查询(实体选择器)和世界(管理所有内容的容器)。
**适用场景:**当游戏中有许多共享行为且相互交互的对象(敌人、投射物、拾取物、单位),并且采用状态驱动的更新循环时,ECS 会很有帮助——它将逻辑保留在系统中,将状态保留在组件中,避免代码陷入类的纠缠。对于只有少量定制对象的小型场景,ECS 则会带来额外开销;使用普通对象和渲染循环即可。以下章节介绍了足以使用 EliCS 进行开发的 API;如需查看准确的签名和完整类型定义,请查阅已安装的 elics 类型,而不要依赖本页,因为本页内容可能落后于软件包。
推荐架构
使用 3 个层:输入、状态和视图。
- 将输入映射到状态(输入系统:接收用户输入,将其规范化并应用到状态。复杂输入可以拆分到多个系统中处理(例如,原始事件 → 规范化操作 → 状态更新)。每个输入系统都必须提供可命令式调用所有输入的方法,以便以编程方式模拟和测试用户输入。)
- 每帧推进状态(状态系统:每帧推进游戏状态(例如,敌人追逐玩家)。)
- 将状态同步到视图(视图系统:在视图中反映状态。必要时,在初始化期间订阅实体的创建和移除事件(例如,创建/销毁 3D 对象或 UI),并在每帧更新频繁变化的数据。)
推荐文件结构
src/components/*.ts:每个组件一个文件(例如,position.ts、velocity.ts)。组件只保存状态;行为应放在系统中。src/systems/*.ts:每个系统一个文件(例如,input.ts、physics.ts)。每个系统都导出初始化函数和逐帧更新函数。src/seed.ts:构建启动实体并为其添加组件,使游戏一开始就可以游玩。src/main.ts:组合游戏。加载种子、注册系统、运行初始化,然后逐帧推进所有状态并同步到视图。
创建世界
“世界”是所有 ECS 元素共存的地方。
import { World } from 'elics'
const world = new World()
定义组件
组件是用于定义实体属性的数据容器。
import { createComponent, Types } from 'elics'
import { world } from './world.ts'
// Available Types:
// - Scalars: Types.Int8, Types.Int16, Types.Float32, Types.Float64, Types.Boolean, Types.String
// - Vectors: Types.Vec2, Types.Vec3, Types.Vec4, Types.Color (RGBA 0-1)
// - Entity reference: Types.Entity
// - Enum: Types.Enum
// - Types.Object holds any JS value (a Three.js mesh, an array) — not an ECS reference
// Define enums for type-safe state management using const assertions
export const UnitType = {
Infantry: 'infantry',
Cavalry: 'cavalry',
Archer: 'archer',
} as const
export const CombatState = {
Idle: 'idle',
Moving: 'moving',
Attacking: 'attacking',
Defending: 'defending',
} as const
export const Position = createComponent(
'Position',
{
value: { type: Types.Vec3, default: [0, 0, 0] },
},
'3D position coordinates',
)
//register components directly after creating them
world.registerComponent(Position)
export const Velocity = createComponent(
'Velocity',
{
value: { type: Types.Vec3, default: [0, 0, 0] },
},
'3D velocity vector',
)
world.registerComponent(Velocity)
export const Health = createComponent(
'Health',
{
value: { type: Types.Float32, default: 100 },
},
'Entity health points',
)
world.registerComponent(Health)
export const Unit = createComponent(
'Unit',
{
type: { type: Types.Enum, enum: UnitType, default: UnitType.Infantry },
state: { type: Types.Enum, enum: CombatState, default: CombatState.Idle },
damage: { type: Types.Float32, default: 10, min: 1, max: 100 },
armor: { type: Types.Int16, default: 0, min: 0, max: 50 },
morale: { type: Types.Float32, default: 1.0, min: 0.0, max: 1.0 },
},
'Military unit with combat statistics',
)
world.registerComponent(Unit)
// Entity references
export const Target = createComponent('Target', {
entity: { type: Types.Entity, default: null },
})
world.registerComponent(Target)
**重要:**向量类型(
Vec2、Vec3、Vec4、Color)必须通过getVectorView()访问,而不能使用getValue()/setValue()。如果使用了错误的访问器,EliCS 会在运行时抛出异常。
创建实体
实例化实体并为其添加组件:
const unit = world.createEntity()
unit.addComponent(Position, { value: [10, 20, 30] })
unit.addComponent(Velocity)
unit.addComponent(Health)
unit.addComponent(Unit, {
type: UnitType.Infantry,
state: CombatState.Moving,
damage: 15,
armor: 20,
morale: 0.8,
})
// Reading and writing scalar values
const damage = unit.getValue(Unit, 'damage') // returns number
unit.setValue(Unit, 'state', CombatState.Attacking)
// Reading and writing vector values (returns TypedArray subarray)
const pos = unit.getVectorView(Position, 'value')
pos[0] = 100 // Modify X directly
// Check component existence
if (unit.hasComponent(Health)) {
/* ... */
}
// Remove component or destroy entity
unit.removeComponent(Velocity)
unit.destroy() // entity.active becomes false
创建系统
系统包含核心逻辑。
import { createSystem, Entity } from 'elics'
import { Object3D } from 'three'
import { scene } from './scene.ts'
import { unitModel } from './unit-model.ts'
const movementQueryConfig = {
movables: { required: [Position, Velocity] },
}
export class MovementSystem extends createSystem(movementQueryConfig) {
update(delta: number, time: number) {
this.queries.movables.entities.forEach((entity: Entity) => {
const position = entity.getVectorView(Position, 'value')
const velocity = entity.getVectorView(Velocity, 'value')
position[0] += velocity[0] * delta
position[1] += velocity[1] * delta
position[2] += velocity[2] * delta
})
}
}
const unitViewQueryConfig = {
units: { required: [Unit, Position] },
}
export class UnitViewSystem extends createSystem(unitViewQueryConfig) {
public readonly models = new Map<Entity, Object3D>()
init() {
const create = (entity: Entity) => {
const newModel = unitModel.clone()
scene.add(newModel)
this.models.set(entity, newModel)
}
const destroy = (entity: Entity) => {
const oldModel = this.models.get(entity)!
scene.remove(oldModel)
this.models.delete(entity)
}
// Loop through existing and subscribe to new query entities
this.queries.units.entities.forEach(create)
this.queries.units.subscribe('qualify', create)
this.queries.units.subscribe('disqualify', destroy)
}
update(delta: number, time: number) {
this.queries.units.entities.forEach((entity) => {
const model = this.models.get(entity)!
const pos = entity.getVectorView(Position, 'value')
model.position.set(pos[0], pos[1], pos[2])
})
}
destroy() {
// Cleanup when system is unregistered
}
}
// Register with priority (lower runs first)
world.registerSystem(MovementSystem, { priority: 0 })
world.registerSystem(UnitViewSystem, { priority: 10 })
访问系统
使用 world.getSystem(...) 获取已注册的系统实例:
const enemySystem = world.getSystem(EnemySystem)!
// Access system properties and methods
enemySystem.spawnEnemy(position)
系统资源加载
框架会调用 init()(在 registerSystem 时)和 update()(每次调用 world.update 时)。它没有 load() 生命周期钩子——只有这两个钩子。如需异步加载资源,请为系统提供自己的 load() 方法,并在注册后、动画循环开始前自行调用它。这只是普通方法,不是框架钩子:除非你主动调用,否则不会有人调用它。
export class EnemySystem extends createSystem(queryConfig) {
private model!: Group
init() {
/* called by registerSystem() — synchronous only */
}
async load() {
// your own method — you must await it yourself (see below)
this.model = (await new GLTFLoader().loadAsync('/models/enemy.glb')).scene
}
update(delta: number, time: number) {
/* called every frame by world.update */
}
}
在 main.ts 中,先注册系统(这会运行 init()),然后在循环开始前等待 load() 完成:
world.registerSystem(EnemySystem) // runs init()
await world.getSystem(EnemySystem)!.load()
renderer.setAnimationLoop(() => { ... })
系统生命周期控制
使用 stop() 和 play() 动态暂停和恢复系统:
const enemySystem = world.getSystem(EnemySystem)!
// Pause the system (e.g., during cutscenes or menus)
enemySystem.stop()
// Check if system is paused
if (enemySystem.isPaused) {
console.log('Enemy system is paused')
}
// Resume the system
enemySystem.play()
**示例用例:**在对话期间暂停物理模拟,然后在玩家重新获得控制权时恢复。
查询谓词
使用 where 子句按组件值筛选实体:
const queryConfig = {
lowHealth: {
required: [Health, Unit],
excluded: [Target], // Exclude entities with this component
where: [{ component: Health, key: 'value', op: 'lt', value: 30 }],
},
}
运算符:eq(等于)、ne(不等于)、lt(小于)、le(小于或等于)、gt(大于)、ge(大于或等于)、in(在数组中)、nin(不在数组中)。
更新世界
使用 delta 和 time 更新世界。
function render() {
...
const delta = clock.getDelta()
...
world.update(delta, clock.elapsedTime)
...
}