CastStage
多个角色按脚本编排的一场戏。
属于
@aora-bot/surfaces,不在@aora-bot/react里:pnpm add @aora-bot/surfaces @aora-bot/react,并在 React 样式之后引入@aora-bot/surfaces/styles.css。
脚本就是数据
一场戏由一组 members 和一组 steps 组成。每一步说明每个人站在哪里——x、y 是相对舞台中心的像素,可选的 scale,以及这一步要采用的可选 state——以及停留多久。某一步的 positions 里没有列出的成员,在这一步就是下场:缩小并淡出,而不是凭空消失。
因为两半都是普通数据,宿主自己写编排,这个组件永远不知道这场戏讲的是什么。引导流程可以重排它的场次,而任何移动逻辑都不必知情。
没有帧循环的弹簧
移动是 CSS transition,但缓动曲线是从引擎自己的弹簧采样成 linear() 函数得到的。真正的弹簧需要一个帧循环,而给四个只是在滑行的成员开帧循环,会和它们各自已经在为脸跑的动画抢资源。对解析解采样能得到同一条曲线——包括过冲——代价只是一个字符串。spring 调节刚度(omega)和阻尼(zeta);moveMs 是过渡时长。
onArrive(memberId, stepIndex) 在某个成员的 transform 结束时触发,每个成员每一步一次,所以宿主可以恰好在角色落地时触发下一件事,而不是靠一个只能猜的定时器。
useCastScene
这个 hook 只决定何时,从不决定何处。它持有当前的 stepIndex,在 playing 时于每一步的 durationMs 之后前进,并在最后一步停下,除非设置了 loop。next、prev、play、pause、goTo 是播放控制;onStep 报告每次变化。autoplay 让它在挂载时就开始跑。
CastMember 与 AvatarSlot
CastMember 是嵌套在两个盒子里的一个角色:外层盒子负责旅程——位置、缩放、透明度在同一个 transition 里——内层盒子负责原地起伏(bobPx、bobMs),所以一个成员可以在滑过舞台的同时原地漂浮。请给每个成员不同的 bobMs,让这群人不要同步呼吸。成员都是 StagedMark,所以舞台上方要有 MarkStageProvider。
AvatarSlot 是角色最后飞进去的那个空圆:一圈虚线轮廓在戏还在演时说「这里该有个东西」,arrived 置真后交叉淡入到 children——两层结构,中间的盒子永远不会空着。它不知道自己在哪里;用普通 CSS 去定位,放在舞台里或旁边的表单里都行。
API
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
members* | readonly CastMemberSpec[] | — | — |
steps* | readonly CastStep[] | — | — |
children | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null | — | Scenery: a device frame, an `AvatarSlot`, a caption. Rendered over the stage. |
className | string | — | — |
face | "cute" | "eyes" | "line" | — | — |
heightPx | number | 320 | — |
lineWeight | number | — | — |
moveMs | number | 560 | How long a move takes. Also the transition duration. |
nose | "slant" | "angular" | "hook" | "long" | "curve" | "bow" | null | — | — |
onArrive | ((memberId: string, stepIndex: number) => void) | — | Called when a member finishes a move. Fires once per member per step. |
sizePx | number | 80 | Default member size, for members that do not set their own. |
spring | { omega: number; zeta: number; } | DEFAULT_SPRING | Spring the easing is sampled from: stiffness and damping. |
stepIndex | number | 0 | Which step is on screen. Drive it with `useCastScene`. |
style | CSSProperties | — | — |
widthPx | number | 320 | Stage box. Positions are measured from its centre, so both are needed. |
CastMember
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
durationMs* | number | — | — |
easing* | string | — | Easing for the move, as a `linear()` function from the engine's spring. |
member* | CastMemberSpec | — | — |
position* | CastPosition | null | — | Where to stand. `null` takes the member off stage. |
className | string | — | — |
face | "cute" | "eyes" | "line" | — | — |
lineWeight | number | — | — |
nose | "slant" | "angular" | "hook" | "long" | "curve" | "bow" | null | — | — |
onArrive | ((id: string) => void) | — | Called when this member finishes moving. |
sizePx | number | — | — |
style | CSSProperties | — | — |
AvatarSlot
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
arrived | boolean | false | The member has landed: the outline fades out and `children` fade in. |
children | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null | — | What the slot holds once something has landed in it. |
className | string | — | — |
label | string | — | — |
sizePx | number | 64 | — |
style | CSSProperties | — | — |