• 简体中文
  • 表情

    一个表情由四样东西组成:眼睛、嘴、腮红,和部分状态带出的特效。默认的 cute 脸给每个状态配一组,下面逐一讲。

    dot
    dotSmall
    dotWide
    dotLeft
    dotRight
    dotUp
    dotDown
    dotLook
    dotHalf
    dotSuspicious
    arch
    archWide
    curve
    curveSleepy
    cross
    star
    sad
    mad
    wink

    上面是 cute 脸的全部眼型,每一种都是几个数字。眼型用 expression 选项固定住时,名字就是这里的 id;不固定时,由状态决定。

    一只眼睛是一段曲线笔画

    interface EyeSpec {
      dx: number; // 相对锚点的偏移
      dy: number;
      len: number; // 中心线半长
      width: number; // 半厚度
      angle: number; // 中心线方向
      bend: number; // 中点向侧面弯的程度
    }

    半长为 len 的中心线沿 angle 指向,向侧面弯 bend,再赋厚度 width,两端圆帽。圆点眼是一段几乎没有长度的笔画,width 就是它的半径;笑眼是带负弯的水平笔画;闭眼是带正弯的水平笔画。sad、mad 在圆点上方多带一道眉,cross 与 star 是叉和四角星。

    idlenwidthanglebenddx / dy
    dot0.0211.5700 / 0
    dotSmall0.020.621.5700 / 0
    dotWide0.021.351.5700 / 0
    dotLeft0.0211.570-0.45 / 0
    dotRight0.0211.5700.45 / 0
    dotUp0.0211.5700 / -0.45
    dotDown0.020.961.5700 / 0.39
    dotLook0.0211.5700.43 / -0.48
    dotHalf0.450.450.0000 / 0.2
    dotSuspicious0.450.450.0000 / 0.2
    arch1.090.370.00-0.870 / 0.43
    archWide1.20.340.00-1.020 / 0.45
    curve1.090.370.000.430 / 0
    curveSleepy10.340.000.620 / 0.1
    cross1.230.370.7900 / 0
    star0.020.21.5700 / 0
    sad0.020.911.5700 / 0.26
    mad0.020.951.5700 / 0.12
    wink10.360.00-0.80 / 0.43
    import { CUTE_EYE_TOPOLOGIES, CUTE_EYE_IDS } from '@aora-bot/engine';
    
    export default function Demo() {
      return (
        <table>
          <thead>
            <tr>
              <th>id</th>
              <th>len</th>
              <th>width</th>
              <th>angle</th>
              <th>bend</th>
              <th>dx / dy</th>
            </tr>
          </thead>
          <tbody>
            {CUTE_EYE_IDS.map((id) => {
              const [left] = CUTE_EYE_TOPOLOGIES[id];
              return (
                <tr key={id}>
                  <td>
                    <code>{id}</code>
                  </td>
                  <td>{left.len}</td>
                  <td>{left.width}</td>
                  <td>{left.angle.toFixed(2)}</td>
                  <td>{left.bend}</td>
                  <td>
                    {left.dx} / {left.dy}
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      );
    }

    眼睛大小取身体在脸所在高度的宽度的 7.8%,再以身体高度的 8% 封顶、5% 做下限,所以窄身体的眼睛也看得清。两眼相距 2.6 个眼睛半径——就是一张婴儿脸的比例,脸也放得比较靠近身体中心。

    嘴

    none
    smile
    cat
    grin
    laugh
    flat
    frown
    o
    dot
    wavy
    smirk
    pout

    嘴和眼睛一样是几个数字,每个字段独立插值,所以状态切换时嘴是连续地变形,而不是替换。none 是不画嘴;每个状态用 STATE_CUTE_MOUTH 指定一张。cat 是 ω 形的两个小弧;张开的嘴越开,笔画越细,开到一定程度改成实心。

    六个状态会说话:composing、dictating、messaging、sending、laughing 和 humming,它们的嘴在两个不可通约的正弦波上一开一合。prefers-reduced-motion 下关闭。

    嘴和腮红按 50% 跟随眼睛的视线,所以转头时整张脸一起倾斜,而不是眼睛在动、嘴留在原地。

    腮红

    腮红是脸颊上两片扁而淡的粉色椭圆,强度 0 到 1,按状态给出(STATE_BLUSH),在弹簧上渐变。只有开心一类的情绪、害羞和熟睡会脸红;处理中的状态一律不红,腮红出现得太多就不再表达什么。

    sleeping0.6
    happy1
    excited1
    laughing1
    playful0.6
    proud0.6
    celebrate1
    shy1
    humming0.6
    import { STATE_BLUSH } from '@aora-bot/engine';
    import { CharacterMark } from '@aora-bot/react';
    
    export default function Demo() {
      return (
        <div className="site-demo">
          {Object.entries(STATE_BLUSH)
            .filter(([, amount]) => amount > 0)
            .map(([state, amount]) => (
              <div className="site-demo__cell" key={state}>
                <CharacterMark shape="mochi" color="blue" state={state as never} sizePx={72} />
                <span className="site-demo__label">{state}</span>
                <span className="site-demo__label" style={{ opacity: 0.6 }}>
                  {amount}
                </span>
              </div>
            ))}
        </div>
      );
    }

    情绪特效

    有些状态在身体之外还带着一个特效:睡着的 Zz,开心的粉心,难过的泪滴,惊讶的三道线,拒绝的红色感叹号,庆祝的金色闪光,工作中右上角转圈的小弧。特效都放在身体旁边,从不绕着身体画一圈。特效是时间的纯函数(evaluateFx),随帧快照的 fx 字段交给渲染器,所以向量、像素、颜文字三个系列自动一致。

    STATE_FX 把状态映射到特效;下表直接读它,也包括腰带状态改用的活动特效(见状态)。

    特效状态
    zzsleeping、drowsy
    sparkleproud、celebrate
    bangrefusing、alerting
    strokessurprised、scared
    hearthappy
    tearsad
    arcworking
    dotsthinking
    ellipsiswaiting
    orbitorbit
    bubblesmessaging
    spinnerloading
    progressprogress
    rippleradar
    wavesdictating
    riseuploading
    fallreceiving
    sendsending
    gatherspawning
    pencilwriting
    import { FX_KINDS, STATE_FX } from '@aora-bot/engine';
    
    export default function Demo() {
      return (
        <table>
          <thead>
            <tr>
              <th>特效</th>
              <th>状态</th>
            </tr>
          </thead>
          <tbody>
            {FX_KINDS.map((kind) => (
              <tr key={kind}>
                <td>
                  <code>{kind}</code>
                </td>
                <td>
                  {Object.entries(STATE_FX)
                    .filter(([, fx]) => fx === kind)
                    .map(([state]) => state)
                    .join('、') || '—'}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      );
    }

    按状态轮播

    一个状态点名一组眼型,并按随机间隔循环。idle 在圆点和偶尔的左右一瞥之间漂移;searching 大约每半秒换一次。

    statesequenceswitch interval
    idledot → dot → dot → dotLeft → dot → dotRight9000–16000 ms
    thinkingdotLook → dotUp → dotLook1600–2800 ms
    searchingdotLeft → dotRight → dotUp → dot550–1150 ms
    sleepingcurveheld
    angrymadheld
    celebratestar → archWide → wink → arch700–1300 ms
    import { STATE_CUTE_EYES, STATE_EYE_SWITCH_MS } from '@aora-bot/engine';
    
    const rows = ['idle', 'thinking', 'searching', 'sleeping', 'angry', 'celebrate'] as const;
    
    export default function Demo() {
      return (
        <table>
          <thead>
            <tr>
              <th>state</th>
              <th>sequence</th>
              <th>switch interval</th>
            </tr>
          </thead>
          <tbody>
            {rows.map((state) => {
              const ms = STATE_EYE_SWITCH_MS[state];
              return (
                <tr key={state}>
                  <td>
                    <code>{state}</code>
                  </td>
                  <td>{STATE_CUTE_EYES[state].join(' → ')}</td>
                  <td>{ms ? `${ms[0]}–${ms[1]} ms` : 'held'}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
      );
    }

    眨眼

    眨眼不是淡出。眼睛先挤到 5% 高度,再超调到 108%,然后落回 100%。没有超调,它读起来像眼睛短暂消失,而不是眨眼。第一次之后有 14% 的概率再眨一下。

    eyes 与 line 脸的表情

    eyes 和 line 两种脸有它们自己的一套眼睛拓扑,每种也是一段这样的曲线笔画。下面是它们在 eyes 脸上的样子,以及在线条脸的共享线宽下的样子:几何不变,眼睛更小,细的表情被抬到旁边鼻子和嘴的线宽。

    neutral中性
    neutralSoft柔和
    closed闭眼
    half半睁
    wide睁大
    happy开心
    joyful欢快
    angry生气
    squint细眼
    lookLeft左看
    lookRight右看
    lookUp上看
    shy害羞
    suspicious怀疑
    sleepy睡意
    excited兴奋
    dizzy眩晕
    wink眨单眼
    neutral中性
    neutralSoft柔和
    closed闭眼
    half半睁
    wide睁大
    happy开心
    joyful欢快
    angry生气
    squint细眼
    lookLeft左看
    lookRight右看
    lookUp上看
    shy害羞
    suspicious怀疑
    sleepy睡意
    excited兴奋
    dizzy眩晕
    wink眨单眼

    wink 是其中的例外:唯一一种两只眼睛不同的拓扑,一边是开心的弧,另一边是中性的眼。它在 cute 脸里也有一个同名的眼型。

    在 cute 脸上,eyes 与 line 的眼型名会被翻译成最接近的 cute 眼型(happy → arch、angry → mad、dizzy → cross 等),所以同一个 expression 值在每种脸上都有意义。

    眼睛在 eyes 与 line 脸里的大小,取身体在眼睛所在高度的宽度的一个固定比例,再以身体高度封顶,并设一个下限。拟合出的脸盒只决定脸在哪,不决定脸多大。在圆润的身体上,睁开的眼睛大约占身体宽度的五分之一、高度的四分之一,两眼间距略宽于一只眼——就是在球上画一张脸的比例。所有眼形再按一个固定系数横向拉宽,睁眼读作椭圆而不是细缝,闭眼与笑眼的笔画也跨出相同的宽度。最后用一层反缩放补偿每个形状自己的渲染比例,这样眼睛在不同的形状上看起来一样大。

    为什么用参数而不是轮廓

    最直觉的表示是每个表情一组数字化点环,从设计文件导出。

    参数赢在一个具体原因:形变。同样的论证也决定了线条脸的嘴——嘴是六个数字,理由和眼睛是五个数字一样。

    逐点插值两个点环会让点在过渡中途互相穿越。从水平条到垂直条,朴素混合会经过一团纠缠,而不是一次旋转。存储轮廓的实现必须绕开这一点:从点里恢复主轴、混合主轴、再重建轮廓——实际上是把丢掉的参数重新找回来。

    插值五个数字是精确的。每个中间值都是合法的眼睛,因为没有办法表达不合法的眼睛。

    morphEye(a, b, t); // 永远是一只眼睛

    唯一的细节是 angle 走较短的那条路,所以 +40° 的斜视不会经过水平再到 −40°。

    接下来