@cjhd/cj-ecs-observed
v1.2.1
Published
Optional observed runtime for cj-ecs dirty queues and indexed fields
Downloads
379
Readme
cj-ecs-observed
@cjhd/cj-ecs-observed 是 @cjhd/cj-ecs 的可选运行时扩展包,用于把组件 dirty 队列、字段级 dirty 队列、字段索引查询从核心 ECS 里拆出来。
核心原则:
- 不安装 observed runtime 时,核心 ECS 仍可独立运行。
- 需要字段变化、增量同步、调试观察或 indexed 查询时,再显式安装 runtime。
- 最新 API 以装饰器生成的
Component.Fields.XxxFieldRef 为主。 - 旧式
(Component, token)查询仍兼容,但新代码优先使用 FieldRef。
导入
import {
createEcsObservedRuntime,
ecsobs,
EcsDirtyClear,
EcsIndexedCondition,
EcsObservedField,
EcsObservedDecimal,
EcsIndexedField,
} from '@cjhd/cj-ecs-observed';
import { EcsIndexedFieldMode } from '@cjhd/cj-ecs';常用入口:
createEcsObservedRuntime(ecs, options?)ecsobs.install()ecsobs.uninstall()ecsobs.dirtyecsobs.fieldDirtyecsobs.indexecsobs.indexedMatcher()
安装运行时
import { ECS } from '@cjhd/cj-ecs';
import { createEcsObservedRuntime, ecsobs } from '@cjhd/cj-ecs-observed';
const ecs = ECS.create();
createEcsObservedRuntime(ecs);
ecsobs.install();createEcsObservedRuntime(ecs) 会创建 runtime 并绑定到全局 ecsobs.current。ecsobs.install() 会把 runtime 接到 ECS dirty sink 上。安装后:
component.markDirty(mask)会进入组件级 dirty 队列。@EcsObservedField的 setter 会进入字段级 dirty 队列。@EcsObservedDecimal可以捕捉 Decimal 原地修改。@EcsIndexedField会维护长期字段索引集合。
销毁时:
ecsobs.uninstall();
ecs.clear();FieldRef:最新推荐写法
装饰器会在组件类上生成 Fields 表。字段名首字母大写:
HealthComponent.Fields.Current
PositionComponent.Fields.X
StateComponent.Fields.Flags
ProjectileComponent.Fields.QueryActiveFieldRef 包含:
kind:observed或indexedComp:组件类型token:实际字段 token,例如'current'propertyKey:原字段名staticName:Fields下的名字,例如Current
新 API 可以直接传 FieldRef:
ecsobs.fieldDirty.count(HealthComponent.Fields.Current);
ecsobs.fieldDirty.drain(HealthComponent.Fields.Current, handler, {
clear: EcsDirtyClear.All,
});
ecsobs.index.count(
ProjectileComponent.Fields.QueryActive,
EcsIndexedCondition.eq(true),
);
ecsobs.indexedMatcher()
.all(UnitComponent, StateComponent)
.whereFlag(StateComponent.Fields.Flags, StateBits.Stunned)
.build();兼容旧写法:
ecsobs.fieldDirty.count(HealthComponent, HealthComponent.Fields.Current.token);新代码不再推荐使用 HealthComponent.Current 这类旧静态 token 写法。
Clear 模式
推荐使用 EcsDirtyClear 常量:
ecsobs.fieldDirty.drain(HealthComponent.Fields.Current, handler, {
clear: EcsDirtyClear.All,
});可用值:
EcsDirtyClear.Matched:只清理本次命中的 dirty mask。EcsDirtyClear.All:清理组件上的全部 dirty mask。EcsDirtyClear.None:只消费队列项,不清理组件 dirty mask。
默认行为:
ecsobs.dirty.drain(...)更适合组件级 dirty 消费,默认偏向matched。ecsobs.fieldDirty.drain(...)默认是none,因为字段 dirty 常用于网络同步或调试观察,不一定应该顺手清掉组件 dirty。
业务代码建议显式传入 clear。
普通字段观察
@EcsObservedField 用于 number、boolean、string、枚举等普通赋值字段。
class HealthDirtyBits {
static readonly Current = 1 << 0;
}
@ecsclass('HealthComponent')
export class HealthComponent extends EcsComponent {
@EcsObservedField({
dirtyMask: HealthDirtyBits.Current,
})
current = 0;
}使用:
health.current = 10;
ecsobs.fieldDirty.count(HealthComponent.Fields.Current);
ecsobs.dirty.count(HealthComponent, HealthDirtyBits.Current);
ecsobs.fieldDirty.drain(
HealthComponent.Fields.Current,
(entity, health, info) => {
// 同步 health.current,或刷新 UI
},
{ clear: EcsDirtyClear.All },
);同值赋值不会重复入队。未装饰字段不会进入 field dirty,也不会自动标记组件 dirty。
Decimal 字段观察
@EcsObservedDecimal 用于 Decimal 这种会原地修改的对象字段。普通 setter 捕捉不到:
position.x.copy(...);
position.x.addInt(...);
position.x.setInt(...);示例:
class PositionDirtyBits {
static readonly Transform = 1 << 0;
}
@ecsclass('PositionComponent')
export class PositionComponent extends EcsComponent {
@EcsObservedDecimal({
dirtyMask: PositionDirtyBits.Transform,
})
x = new Decimal();
@EcsObservedDecimal({
dirtyMask: PositionDirtyBits.Transform,
})
y = new Decimal();
}查询:
position.x.copy(Decimal.createInt(5));
ecsobs.fieldDirty.count(PositionComponent.Fields.X);
ecsobs.fieldDirty.drain(PositionComponent.Fields.X, handler, {
clear: EcsDirtyClear.All,
});批量修改
多次 Decimal 修改可以合并成一次字段通知:
position.x.runMutationBatch(() => {
position.x.setInt(1);
position.x.addInt(2);
});热路径静默写入
极热路径可以用 setSilent / copySilent 避免 Decimal 自动通知,然后手动合并 dirty:
setXYRaw(xV1: number, xV2: number, yV1: number, yV2: number): void {
const xChanged = this.x.v1 !== xV1 || this.x.v2 !== xV2 || this.x.sign !== 1;
const yChanged = this.y.v1 !== yV1 || this.y.v2 !== yV2 || this.y.sign !== 1;
if (!xChanged && !yChanged) return;
if (xChanged) this.x.setSilent(1, xV1, xV2);
if (yChanged) this.y.setSilent(1, yV1, yV2);
this.markDirty(PositionDirtyBits.Transform);
const sink = (this as any)._dirtySink;
if (sink && typeof sink.notifyFieldDirty === 'function') {
if (xChanged) sink.notifyFieldDirty(this, PositionComponent.Fields.X.token);
if (yChanged) sink.notifyFieldDirty(this, PositionComponent.Fields.Y.token);
}
}组件级 Dirty 查询
组件级 dirty 查询按 mask 消费,适合系统热路径。
const count = ecsobs.dirty.count(PositionComponent, PositionDirtyBits.Spatial);只遍历:
ecsobs.dirty.forEach(PositionComponent, PositionDirtyBits.Spatial, (entity, position, info) => {
// 只读观察,不清队列
});遍历并消费:
ecsobs.dirty.drain(
PositionComponent,
PositionDirtyBits.Spatial,
(entity, position, info) => {
// 处理空间位置变化
},
{
stable: true,
budget: 128,
clear: EcsDirtyClear.Matched,
},
);info 包含:
requestedMaskmatchedMaskcomponentDirtyMaskdirtyVersionentityUuidcomponentUuid
字段级 Dirty 查询
字段级 dirty 查询按 FieldRef 消费,适合网络同步、调试工具和细粒度 UI 刷新。
ecsobs.fieldDirty.count(PositionComponent.Fields.X);
ecsobs.fieldDirty.forEach(PositionComponent.Fields.X, (entity, position, info) => {
// 观察 X 字段变化
});
ecsobs.fieldDirty.drain(PositionComponent.Fields.X, (entity, position, info) => {
// 同步 position.x
}, {
clear: EcsDirtyClear.All,
});字段级 info 在组件 dirty info 基础上额外包含:
fieldfieldVersion
fieldVersion 每次字段入队都会递增,适合调试重复变化、网络同步版本检查等场景。
字段索引
@EcsIndexedField 维护长期状态集合。它不是 dirty 队列。
区别:
- dirty 队列表示“发生过变化”,会被
drain消费。 - indexed 字段表示“当前满足条件的集合”,不会因为查询而消失。
Eq 索引
@ecsclass('ProjectileComponent')
export class ProjectileComponent extends EcsComponent {
@EcsIndexedField({ defaultValue: true })
queryActive!: boolean;
}查询:
ecsobs.index.count(
ProjectileComponent.Fields.QueryActive,
EcsIndexedCondition.eq(true),
);
ecsobs.index.forEach(
ProjectileComponent.Fields.QueryActive,
EcsIndexedCondition.eq(true),
(entity, projectile, info) => {
// 当前 queryActive=true 的 projectile
},
);字段变化时,runtime 会把组件从旧值桶移动到新值桶。
Flag 索引
const Stunned = 1 << 0;
const Burning = 1 << 1;
@ecsclass('StateComponent')
export class StateComponent extends EcsComponent {
@EcsIndexedField({
defaultValue: 0,
mode: EcsIndexedFieldMode.Flag,
})
flags!: number;
}查询:
ecsobs.index.count(
StateComponent.Fields.Flags,
EcsIndexedCondition.flagAny(Stunned),
);
ecsobs.index.count(
StateComponent.Fields.Flags,
EcsIndexedCondition.flagAll(Stunned | Burning),
);组合索引 Matcher
indexedMatcher() 可以组合 ECS 结构条件和 indexed 字段条件:
const stunnedUnits = ecsobs.indexedMatcher()
.all(UnitComponent, StateComponent)
.exclude(DestroyComponent)
.whereFlag(StateComponent.Fields.Flags, Stunned)
.build();
stunnedUnits.forEachEntity((entity) => {
// entity 有 Unit + State,没有 Destroy,并且 State.flags 命中 Stunned
});建议 matcher 构建后复用,不要每帧重复 build。
完整示例
import { Decimal } from '@cjhd/cj-decimal';
import { ECS, EcsComponent, EcsEntity, ecsclass } from '@cjhd/cj-ecs';
import {
createEcsObservedRuntime,
ecsobs,
EcsDirtyClear,
EcsObservedDecimal,
EcsObservedField,
} from '@cjhd/cj-ecs-observed';
class HealthDirtyBits {
static readonly Current = 1 << 0;
}
class PositionDirtyBits {
static readonly Transform = 1 << 0;
}
@ecsclass('HealthComponent')
class HealthComponent extends EcsComponent {
@EcsObservedField({
dirtyMask: HealthDirtyBits.Current,
})
current = 0;
}
@ecsclass('PositionComponent')
class PositionComponent extends EcsComponent {
@EcsObservedDecimal({
dirtyMask: PositionDirtyBits.Transform,
})
x = new Decimal();
}
const ecs = ECS.create();
createEcsObservedRuntime(ecs);
ecsobs.install();
const entity = ecs.createEntity(EcsEntity);
const health = entity.add(HealthComponent)!;
const position = entity.add(PositionComponent)!;
health.current = 10;
position.x.copy(Decimal.createInt(5));
ecsobs.fieldDirty.drain(HealthComponent.Fields.Current, (entity, health) => {
// 同步 health.current
}, {
clear: EcsDirtyClear.All,
});
ecsobs.fieldDirty.drain(PositionComponent.Fields.X, (entity, position) => {
// 同步 position.x
}, {
clear: EcsDirtyClear.All,
});本工程测试用例
示例文件在:
assets/cj-ecs-observed/testPhase 3:普通字段和 Decimal 字段 dirty
文件:
assets/cj-ecs-observed/test/EcsObservedPhase3Example.ts覆盖内容:
@EcsObservedField({ dirtyMask })默认使用被装饰属性名。@EcsObservedDecimal({ dirtyMask })默认使用被装饰属性名。- 装饰器生成
Health.Fields.Current/Position.Fields.XFieldRef。 ecsobs.fieldDirty.count(FieldRef)可以直接查询。ecsobs.fieldDirty.drain(FieldRef, handler, options)可以直接消费。- 未装饰字段不会进入 field dirty。
Decimal.copy(...)可以通过 Decimal mutation observer 进入 field dirty。- drain 使用
EcsDirtyClear.All。
验证:
npm run verify:ecs-observed-phase3Phase 4:字段索引和 indexed matcher
文件:
assets/cj-ecs-observed/test/EcsObservedPhase4Example.ts覆盖内容:
- 组件加入 ECS 时,
defaultValue会进入 indexed 集合。 Projectile.Fields.QueryActive是kind=indexed的 FieldRef。ecsobs.index.count(FieldRef, condition)直接查询。- indexed 字段变化时,会在索引桶之间移动。
- flag 索引支持
flagAny和flagAll。 indexedMatcher().whereFlag(FieldRef, flag)可以组合结构条件和字段条件。- 组件移除时会清理 indexed 引用。
验证:
npm run verify:ecs-observed-phase4Phase 5:Decimal 原生 observer 和静默热路径写入
文件:
assets/cj-ecs-observed/test/EcsObservedPhase5Example.ts覆盖内容:
- 普通
new Decimal()默认不会被观察。 - 装饰过的 Decimal 优先使用
bindMutationObserver。 position.x.copy(...)会进入 field dirty。runMutationBatch(...)合并多次 Decimal 原地修改。setSilent(...)可以抑制 Decimal 自动通知。- 热路径可以手动
markDirty(...)+notifyFieldDirty(component, FieldRef.token)。 - 查询处使用
Position.Fields.X/Position.Fields.Y。 - drain 使用
EcsDirtyClear.All。
验证:
npm run verify:ecs-observed-phase5Phase 6:空间 dirty 集成
文件:
assets/cj-ecs-observed/test/EcsObservedPhase6SpatialExample.ts覆盖内容:
- Position dirty 可以驱动空间索引更新。
- dirty drain 可以按 budget 分帧处理。
- 空间查询可以由消费后的 dirty 组件更新。
- 组件从 ECS 移除后,observed runtime 会清理相关队列引用。
验证:
npm run verify:ecs-observed-phase6发布前验证
运行全部 source contract:
npm run verify:ecs-observed-phase3
npm run verify:ecs-observed-phase4
npm run verify:ecs-observed-phase5
npm run verify:ecs-observed-phase6再对本次触碰的 TypeScript 文件做 targeted transpile 检查。这个 Cocos 工程里全量 tsc 容易受到 Cocos 生成路径、资源脚本和 db 路径影响,通常不适合作为小改动的主要验证方式。
迁移说明
旧 token 查询:
ecsobs.fieldDirty.drain(HealthComponent, 'current', handler, {
clear: EcsDirtyClear.All,
});新推荐写法:
ecsobs.fieldDirty.drain(HealthComponent.Fields.Current, handler, {
clear: EcsDirtyClear.All,
});旧 indexed 查询:
ecsobs.index.count(ProjectileComponent, 'queryActive', EcsIndexedCondition.eq(true));新推荐写法:
ecsobs.index.count(
ProjectileComponent.Fields.QueryActive,
EcsIndexedCondition.eq(true),
);新代码统一使用:
- 装饰器默认字段名。
Component.Fields.XxxFieldRef。EcsDirtyClear。EcsIndexedCondition。
