• 简体中文
  • MarkStageProvider

    让长得一样的角色共享同一份动画。

    import { MarkStageProvider, StagedMark, COLOR_IDS } from '@aora-bot/react';
    
    export default function Demo() {
      return (
        <MarkStageProvider>
          <div className="site-demo">
            {Array.from({ length: 44 }, (_, i) => (
              <StagedMark
                key={i}
                shape="pebble"
                state={i % 3 === 0 ? 'happy' : i % 3 === 1 ? 'thinking' : 'idle'}
                color={COLOR_IDS[i % COLOR_IDS.length]}
                sizePx={34}
              />
            ))}
          </div>
        </MarkStageProvider>
      );
    }

    上面四十四个角色。三个正在运行的引擎。

    它怎么工作

    Provider 为每一种不同的外观保留一个隐藏角色,每个可见实例都变成指向它的 <use href="#…">。浏览器复用源这一帧渲染出的内容,所以一个镜像只花一个元素,没有动画工作。

    外观是形状、状态、脸型和笔

    key 由形状、状态、脸型、鼻子、线宽、强调与暂停组成。墨色和尺寸被刻意排除。带 hud 的角色会整个离开舞台、自己跑一个引擎:表盘是实时读数,按它的值做 key 会每一帧都铸出一个新源。

    脸型和鼻子包含在内,因为镜像显示的就是源画出的东西:线条脸的角色不能借用别的脸的源。两个鼻子不同的线条脸 blob 是两个源。默认脸上的鼻子没有意义,所以从 key 里丢掉,不会拆分舞台。

    墨色是 CSS 自定义属性,可继承的自定义属性在使用处解析而不是定义处——所以每个镜像都可以是不同墨色,却共享同一份动画。尺寸设在外层元素上,因此也可以自由变化。

    这是整套设计,也很容易搞错。按 agent 身份给舞台建索引读起来很自然,因为应用手里拿着的就是这么一份列表。但它什么都共享不了:每个 agent 按定义都是唯一的,五十个 agent 就是五十个引擎。按外观建索引,五十个都是 idle blob 的 agent 恰好只要一个。

    上限

    maxSources 限制同时运行的引擎数,默认 36。超出上限后认领仍会被记录,但不再托管新的源,因此页面的退化方式是让一部分角色静止,而不是让所有角色都变慢。

    认领会合并

    滚动时 IntersectionObserver 可能在一帧里产生几十次认领。分别提交每一次会在主线程最忙的时刻把 Provider 重渲染几十次,所以认领被批成每帧一次更新。

    源的尺寸

    隐藏源渲染在 96 像素,而不是收成没有。任何测量自身元素的东西读到的是源的盒子,而不是镜像的,所以缩到几个像素的源会让每个镜像都表现得像自己很小。

    在 Provider 外面

    StagedMark 会回退到自己的 CharacterMark,所以使用它的组件可以单独工作——在测试里,或在文档示例里——树上不必有 Provider。

    style 与 ref

    style 最后应用,所以在那里设置的 --aora-fg 会覆盖由 color 推出的墨色。这是把一个可动画的自定义属性——而不是固定墨色——交给角色的唯一办法,群组活动的墨色过渡就靠它。

    ref 暴露和 CharacterMark 相同的 CharacterHandle,但只在这个角色自己拥有引擎时才有效——对 StagedMark 来说就是在 provider 之外。在 provider 之内,每个角色都是舞台隐藏源的镜像,与所有同外观的角色共用动画,无法单独动作;那种情况下句柄干脆不挂载,usePokeCycle 会报告没有什么可戳。

    import { useRef, type CSSProperties } from 'react';
    import { MarkStageProvider, StagedMark, type CharacterHandle } from '@aora-bot/react';
    
    export default function Demo() {
      const own = useRef<CharacterHandle>(null);
      const mirror = useRef<CharacterHandle>(null);
      return (
        <div className="site-demo site-demo--center">
          <MarkStageProvider>
            <StagedMark
              shape="hex"
              color="cyan"
              state="happy"
              sizePx={72}
              style={{ '--aora-fg': '#0ea5e9' } as CSSProperties}
            />
            <StagedMark ref={mirror} shape="hex" color="cyan" state="happy" sizePx={72} />
          </MarkStageProvider>
          <StagedMark ref={own} shape="hex" color="cyan" state="happy" sizePx={72} />
          <button className="site-btn" onClick={() => (mirror.current ?? own.current)?.bounce()}>
            bounce
          </button>
        </div>
      );
    }

    第一个角色通过 style 改了墨色;第二个是镜像,句柄保持 null,于是按钮落到第三个——它在 provider 之外,跑自己的引擎。要有意让整组角色一起动,走共享的角色指令,而不是逐个 ref。

    API

    属性类型默认值说明
    children*string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null——
    maxSourcesnumber36Cap on simultaneously running engines. Claims past the cap fall back to a static render rather than degrading every animation on the page.

    StagedMark

    属性类型默认值说明
    classNamestring——
    color"black" | "brown" | "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "violet" | "magenta" | "pink" | "gray"——
    emotionIdstring—Protocol id, for hosts that speak the wire format. Ignored when `state` is given.
    emphasisbooleanfalseEnlarge and converge the eyes. Part of the shared appearance.
    eyeColorstring——
    face"cute" | "eyes" | "line"—Face style. Part of the shared appearance, so line-face marks share a line-face source.
    hudHudSpec | nullnullA dial in the ring outside the body. `null` removes it. A HUD spec is a live readout whose value moves every tick, so unlike the other geometry props it cannot be folded into the appearance key — each new value would mint a source and orphan the last one. A mark with a HUD therefore leaves the stage and runs its own engine, which is what a per-instance readout needs anyway.
    lineWeightnumber—Multiplier on the shared line weight. Part of the shared appearance, so a heavier-pen mark gets its own source rather than mirroring a light one.
    nose"slant" | "angular" | "hook" | "long" | "curve" | "bow" | null—Nose for the line face. Unlike `CharacterMark`, the default here is `null` rather than seed-derived: mirrors of one source must agree on a nose, and "the seed's nose" differs per mark.
    pausedbooleanfalseHold the current frame. Part of the shared appearance.
    preset"mochi" | "sprout" | "boba" | "dot" | "pip" | "byte" | "momo" | "bao" | "teal" | "tato" | "hexa" | "tri" | "gemma" | "nimbo" | "dew" | "leafy" | "bloom" | "lucky" | "shroom" | "oak" | "pokey" | "nashi" | "pomme" | "berry" | "cheri" | "twinkle" | "luv" | "ember" | "chime" | "jelly" | "beep" | "luna" | "finn" | "inky" | "miso" | "hop" | "bruno" | "chirp" | "waddle" | "oink" | "rusty" | "kiki" | "maru" | "zip" | "fuzz" | "lulu"—A named character from the cast. Supplies `shape`, `color`, `face` and `nose` when they are not given.
    refRef<CharacterHandle>—Flourishes, when this mark owns an engine. A mirror shares its animation with every other mark of the same appearance and so cannot move alone; the handle is simply never attached in that case, and `usePokeCycle` reports back that there was nothing to poke.
    shape"mochi" | "circle" | "blob" | "pebble" | "egg" | "bun" | "tofu" | "pill" | "hex" | "onigiri" | "gem" | "cloud" | "drop" | "leaf" | "flower" | "clover" | "sprout" | "mushroom" | "acorn" | "cactus" | "pear" | "apple" | "strawberry" | "cherry" | "star" | "heart" | "flame" | "bell" | "ghost" | "bot" | "crescent" | "boba" | "fish" | "octopus" | "cat" | "bunny" | "bear" | "chick" | "penguin" | "pig" | "fox" | "bob" | "topknot" | "spiky" | "curly" | "twinbuns"——
    sizePxnumber28—
    state"idle" | "sleeping" | "drowsy" | "bored" | "waking" | "powering-down" | "listening" | "thinking" | "searching" | "reading" | "writing" | "working" | "loading" | "progress" | "radar" | "orbit" | "dictating" | "uploading" | "sending" | "receiving" | "messaging" | "waiting" | "spawning" | "composing" | "dragging" | "happy" | "excited" | "laughing" | "playful" | "proud" | "celebrate" | "bouncing" | "shy" | "sad" | "angry" | "confused" | "suspicious" | "surprised" | "scared" | "curious" | "humming" | "alerting" | "notifying" | "refusing"——
    styleCSSProperties—Applied last, so `--aora-fg` here overrides the ink derived from `color`. That is the only way to hand a mark an animatable custom property instead of a fixed colour, which is what the group activity ink transition needs.
    styleIdStyleId—Style series. Part of the shared appearance, so a pixel mark gets a pixel source. Named `styleId` because `style` is the inline CSS. Falls back to the surrounding `AppearanceProvider`.
    styleOptionsRecord<string, unknown>—Parameters of the series. Part of the shared appearance, compared by value after the engine's own clamping, so `{}` and the defaults share a source.
    variantStyleVariant—Paint variant within the series. Part of the shared appearance.

    接下来