npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.Xxx FieldRef 为主。
  • 旧式 (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.dirty
  • ecsobs.fieldDirty
  • ecsobs.index
  • ecsobs.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.currentecsobs.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.QueryActive

FieldRef 包含:

  • kindobservedindexed
  • Comp:组件类型
  • token:实际字段 token,例如 'current'
  • propertyKey:原字段名
  • staticNameFields 下的名字,例如 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 包含:

  • requestedMask
  • matchedMask
  • componentDirtyMask
  • dirtyVersion
  • entityUuid
  • componentUuid

字段级 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 基础上额外包含:

  • field
  • fieldVersion

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/test

Phase 3:普通字段和 Decimal 字段 dirty

文件:

assets/cj-ecs-observed/test/EcsObservedPhase3Example.ts

覆盖内容:

  • @EcsObservedField({ dirtyMask }) 默认使用被装饰属性名。
  • @EcsObservedDecimal({ dirtyMask }) 默认使用被装饰属性名。
  • 装饰器生成 Health.Fields.Current / Position.Fields.X FieldRef。
  • 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-phase3

Phase 4:字段索引和 indexed matcher

文件:

assets/cj-ecs-observed/test/EcsObservedPhase4Example.ts

覆盖内容:

  • 组件加入 ECS 时,defaultValue 会进入 indexed 集合。
  • Projectile.Fields.QueryActivekind=indexed 的 FieldRef。
  • ecsobs.index.count(FieldRef, condition) 直接查询。
  • indexed 字段变化时,会在索引桶之间移动。
  • flag 索引支持 flagAnyflagAll
  • indexedMatcher().whereFlag(FieldRef, flag) 可以组合结构条件和字段条件。
  • 组件移除时会清理 indexed 引用。

验证:

npm run verify:ecs-observed-phase4

Phase 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-phase5

Phase 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.Xxx FieldRef。
  • EcsDirtyClear
  • EcsIndexedCondition