ECS随笔2

文章目录

目标-pureEcs

  • entity只做ID
  • component只放数据
  • system只写逻辑
  • view层只负责表现
  • 输入(命令)、网络(命令)、配置、资源、UI((命令))都作为外围系统接入。
  • 不要从继承树开始设计,应该从"一个战斗世界里有哪些数据"和"每帧哪些规则处理这些数据"开始设计。

#mermaid-svg-hhDK3TcjKl3xiuEV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hhDK3TcjKl3xiuEV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hhDK3TcjKl3xiuEV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hhDK3TcjKl3xiuEV .error-icon{fill:#552222;}#mermaid-svg-hhDK3TcjKl3xiuEV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hhDK3TcjKl3xiuEV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hhDK3TcjKl3xiuEV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hhDK3TcjKl3xiuEV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hhDK3TcjKl3xiuEV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hhDK3TcjKl3xiuEV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hhDK3TcjKl3xiuEV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hhDK3TcjKl3xiuEV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hhDK3TcjKl3xiuEV .marker.cross{stroke:#333333;}#mermaid-svg-hhDK3TcjKl3xiuEV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hhDK3TcjKl3xiuEV p{margin:0;}#mermaid-svg-hhDK3TcjKl3xiuEV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-hhDK3TcjKl3xiuEV .cluster-label text{fill:#333;}#mermaid-svg-hhDK3TcjKl3xiuEV .cluster-label span{color:#333;}#mermaid-svg-hhDK3TcjKl3xiuEV .cluster-label span p{background-color:transparent;}#mermaid-svg-hhDK3TcjKl3xiuEV .label text,#mermaid-svg-hhDK3TcjKl3xiuEV span{fill:#333;color:#333;}#mermaid-svg-hhDK3TcjKl3xiuEV .node rect,#mermaid-svg-hhDK3TcjKl3xiuEV .node circle,#mermaid-svg-hhDK3TcjKl3xiuEV .node ellipse,#mermaid-svg-hhDK3TcjKl3xiuEV .node polygon,#mermaid-svg-hhDK3TcjKl3xiuEV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hhDK3TcjKl3xiuEV .rough-node .label text,#mermaid-svg-hhDK3TcjKl3xiuEV .node .label text,#mermaid-svg-hhDK3TcjKl3xiuEV .image-shape .label,#mermaid-svg-hhDK3TcjKl3xiuEV .icon-shape .label{text-anchor:middle;}#mermaid-svg-hhDK3TcjKl3xiuEV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hhDK3TcjKl3xiuEV .rough-node .label,#mermaid-svg-hhDK3TcjKl3xiuEV .node .label,#mermaid-svg-hhDK3TcjKl3xiuEV .image-shape .label,#mermaid-svg-hhDK3TcjKl3xiuEV .icon-shape .label{text-align:center;}#mermaid-svg-hhDK3TcjKl3xiuEV .node.clickable{cursor:pointer;}#mermaid-svg-hhDK3TcjKl3xiuEV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hhDK3TcjKl3xiuEV .arrowheadPath{fill:#333333;}#mermaid-svg-hhDK3TcjKl3xiuEV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hhDK3TcjKl3xiuEV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hhDK3TcjKl3xiuEV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hhDK3TcjKl3xiuEV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hhDK3TcjKl3xiuEV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hhDK3TcjKl3xiuEV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hhDK3TcjKl3xiuEV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hhDK3TcjKl3xiuEV .cluster text{fill:#333;}#mermaid-svg-hhDK3TcjKl3xiuEV .cluster span{color:#333;}#mermaid-svg-hhDK3TcjKl3xiuEV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hhDK3TcjKl3xiuEV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hhDK3TcjKl3xiuEV rect.text{fill:none;stroke-width:0;}#mermaid-svg-hhDK3TcjKl3xiuEV .icon-shape,#mermaid-svg-hhDK3TcjKl3xiuEV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hhDK3TcjKl3xiuEV .icon-shape p,#mermaid-svg-hhDK3TcjKl3xiuEV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hhDK3TcjKl3xiuEV .icon-shape .label rect,#mermaid-svg-hhDK3TcjKl3xiuEV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hhDK3TcjKl3xiuEV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hhDK3TcjKl3xiuEV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hhDK3TcjKl3xiuEV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Command Layer(输入和网络命令)
Simulation ECS World(核心战斗逻辑)
Event Stream
Snapshot Store
Presentation World(表现层镜像)
Renderer
Config Service
Asset Service
Network Sync
Battle UI
Replay Service(/sync/debug)

Simulation ECS World

逻辑世界,应该满足:

  • 不引用任何显示对象
  • 不播放声音
  • 不直接加载资源
  • 不直接操作 UI
  • 给定相同初始状态和相同输入命令,结果应该一致。

Entity设计

ts 复制代码
const entityId = world.createEntity();
world.add(entityId, IdentityComponent, ...);
world.add(entityId, TransformComponent, ...);
world.add(entityId, HealthComponent, ...);
world.add(entityId, SkillComponent, ...);

Conponent设计

  • Component 只放数据,不放业务方法。
    • 数据
    • 状态
    • 表现意图

System设计

  • 系统应该按固定顺序执行,避免"谁先 tick 不清楚"的问题。
  • 推荐帧内顺序:
    • 命令:消费/合法校验/写入组件
    • 生成:组合创建实体/初始化配置快照
    • AI:根据AIComponent决定下一步意图/不直接播放动作特效/可以写入 CastRequestComponent 或移动目标
    • 索敌:写入数据组件
    • 技能请求:写入数据组件
    • 技能施法:
    • 移动
    • 炮弹
    • 碰撞:输出 HitEvent
    • 受击:
    • 伤害:消费 HitEvent 或 DamageRequest
    • buff
    • 死亡
    • 清理
    • 事件
    • 快照

表现层最佳实践

  • 表现层可以使用普通 OOP,也可以再做一个 Render ECS。核心原则是不污染 Simulation ECS。
  • 数据唯二来源:
    • 事件
    • 快照

Query 和存储设计

  • 从 0 开始时,可以先做简单清晰的 ECS,不必一开始就做复杂 archetype(这是啥?)。
  • 第一版推荐:
ts 复制代码
class World {
    private nextEntityId = 1;
    private alive = new Set<EntityId>();
    private stores = new Map<ComponentType, Map<EntityId, unknown>>();

    createEntity(): EntityId;
    destroyEntity(entity: EntityId): void;
    add<T>(entity: EntityId, type: ComponentType<T>, data: T): void;
    get<T>(entity: EntityId, type: ComponentType<T>): T | undefined;
    remove<T>(entity: EntityId, type: ComponentType<T>): void;
    query(...types: ComponentType[]): Iterable<EntityId>;
  • 等性能成为问题,再升级到(底层实现优化/工程能力):
    • archetype 存储【存储性能】:按"组件组合"给实体分组。比如同时有 Transform + Health + Skill 的实体放一组,方便系统批量遍历。
    • sparse set【存储性能】:一种高效存储 Entity 和 Component 的结构,常用于快速判断"某实体有没有某组件"、快速增删组件。
    • SoA 连续数组【存储性能】:Structure of Arrays,把同类字段分开连续存,比如所有 x 放一个数组、所有 y 放一个数组,遍历性能更好。
    • changed flag【系统调度和刷新效率】:变化标记。比如血量没变就不刷新血条,位置没变就不同步 View。
    • command buffer【系统调度和刷新效率】:命令缓冲。System 遍历时不立刻创建/删除实体,而是先记录命令,等这一帧末尾统一执行,避免边遍历边改集合出 bug。
  • 不要过早为了性能牺牲可理解性。

Command Buffer

  • System 遍历时不要直接创建/删除大量实体,推荐每帧固定在 CleanupSystem 或 CommandBufferFlushSystem 统一提交。
  • 这样可以避免遍历组件时修改集合导致 bug。