bloub-to-vue-and-react 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ import { type StateId } from './states';
2
+ /**
3
+ * 一个循环就是一个剪辑:由一系列块组成,每个块是一个保持选定时长的状态。
4
+ * 这是目录中"编辑器"的部分,并且它保留了这些规则——纯数据,没有时钟,
5
+ * 没有 Vue 导入:同一个循环必须能够被测试、播放器和时间轴重新读取。
6
+ *
7
+ * 块没有标识符:它就是列表中的一个位置,渲染的键就是索引。
8
+ * 这让 localStorage 中的 JSON 保持可读,也让测试保持确定性。
9
+ */
10
+ export interface Block {
11
+ state: StateId;
12
+ duration: number;
13
+ }
14
+ export interface Cycle {
15
+ id: string;
16
+ name: string;
17
+ blocks: Block[];
18
+ }
19
+ /**
20
+ * 所有块的共同下限。引擎只保留一个历史记录槽
21
+ * (`BotEngine.setState` 会覆盖 `prev`),因此如果一个块比下一个块的
22
+ * 入场过渡还短,它就会跳到那一帧而不是平滑过渡。
23
+ *
24
+ * 该值由目录推导而来,而非手写。原来的值是 0.6,之所以能正常工作,
25
+ * 仅仅是因为 0.6 恰好是目录中最长的 `morph`——也就是 `orbit` 的过渡时长。
26
+ * 没有任何东西保证这一点:如果新增一个过渡时长为 0.8 秒的状态,
27
+ * 就会让编辑器出现抖动,而不会有任何测试察觉。现在这个下限会随之联动。
28
+ */
29
+ export declare const MIN_BLOCK: number;
30
+ /**
31
+ * 这是编辑器的一道护栏,而不是度量:拉长一个块没有风险
32
+ * (状态会饱和它们的渐变并保持最终姿态),但一分钟长的块轨道就不再可读了。
33
+ */
34
+ export declare const MAX_BLOCK = 10;
35
+ /**
36
+ * 在编辑和重新读取时,允许的块数和剪辑数量上限。
37
+ *
38
+ * 这些不是产品限制,而是针对恶意存储的边界:存储是可修改的,能容纳几兆字节,
39
+ * 而下游没有任何组件按这种规模来设计——一个包含 150 000 个块的循环,
40
+ * 约 4 MB 的 JSON,会产生 1 500 000 秒的时长、同样多的刻度需要分配,
41
+ * 以及一条 29 700 000 px 宽的轨道。之前在进入 Animations 视图时标签页会卡死。
42
+ *
43
+ * 200 个块相当于半小时的剪辑,远超任何实际用途。
44
+ */
45
+ export declare const MAX_BLOCS = 200;
46
+ export declare const MAX_CYCLES = 50;
47
+ /** 滚轮和缩放调整的步长,单位为秒。 */
48
+ export declare const STEP = 0.1;
49
+ /** 块的最小时长:引擎下限,或该状态的实测时长。 */
50
+ export declare function minDurationOf(state: StateId): number;
51
+ /** 将时长限制在其边界内并对齐到步长,且不残留浮点尾数。 */
52
+ export declare function clampDuration(state: StateId, seconds: number): number;
53
+ export declare function makeBlock(state: StateId): Block;
54
+ /**
55
+ * 从视频测得的剪辑:`SEQUENCE` 的顺序,每个状态保持其测得的时长。
56
+ * 它作为首次启动时的初始内容,之后便归用户所有——可以像其他剪辑一样
57
+ * 编辑和存储。而参考版本则留在代码中:清空存储后它就会回来。
58
+ */
59
+ export declare function defaultCycle(): Cycle;
60
+ export declare function totalDuration(blocks: Block[]): number;
61
+ /** 块在剪辑中的开始时刻。 */
62
+ export declare function offsetOf(blocks: Block[], index: number): number;
63
+ /**
64
+ * 在时刻 `t` 播放的块,以及在其中已流逝的时间。超过最后一个块后会
65
+ * 回到开头:播放是循环的。调用方需确保剪辑不为空。
66
+ */
67
+ export declare function blockAt(blocks: Block[], t: number): {
68
+ index: number;
69
+ elapsed: number;
70
+ };
71
+ /**
72
+ * 在剪辑末尾添加一个动画(右侧面板或「+」卡片)。
73
+ *
74
+ * 与重新读取一样,受 `MAX_BLOCS` 上限约束。否则编辑器会允许构建出
75
+ * 比存储重载时所能返回的更大的剪辑,工作成果就会无声消失——
76
+ * 一个不作为编辑上限的读取上限是陷阱,而不是保护。
77
+ */
78
+ export declare function blocksWith(blocks: Block[], state: StateId): Block[];
79
+ /** 移动一个块,并返回一个新列表(Vue 状态会被替换)。 */
80
+ export declare function moveBlock(blocks: Block[], from: number, to: number): Block[];
81
+ /** `Mon cycle`、`Mon cycle 2`、`Mon cycle 3`……——绝不出现两个相同的名字。 */
82
+ export declare function uniqueName(base: string, cycles: Cycle[]): string;
83
+ /** 无冲突的标识符,即使面对手动改过的 localStorage 也能保证。 */
84
+ export declare function nextCycleId(cycles: Cycle[]): string;
85
+ /**
86
+ * localStorage 可以被手动修改:我们不信任它,这与对待 URL hash 的规则相同。
87
+ * 凡是无法重新读取的内容都会被静默丢弃,而不是在启动时破坏应用。
88
+ */
89
+ export declare function parseCycles(raw: string | null): Cycle[];
@@ -0,0 +1,110 @@
1
+ export interface DotRender {
2
+ x: number;
3
+ y: number;
4
+ r: number;
5
+ opacity: number;
6
+ /** 显式颜色;默认情况下渲染使用身体自身的颜色 */
7
+ color?: string;
8
+ /**
9
+ * 深度雾化:0 = 融入背景,1 = 身体自身的完整颜色。
10
+ * 混合发生在渲染阶段,只有渲染才知道所选择的颜色。
11
+ */
12
+ depth?: number;
13
+ /**
14
+ * 非圆形形状,以球半径为单位的尺寸,且以原点为中心
15
+ * (倾斜的 "!" 的点是一滴水滴,而不是圆盘)。当提供该字段时,
16
+ * 绘制不再使用 `r`。
17
+ */
18
+ d?: string;
19
+ /** 应用于 `d` 的旋转,单位为度 */
20
+ rot?: number;
21
+ }
22
+ /**
23
+ * 状态所声明的内容:弧的几何形状保持以球半径为单位,
24
+ * 由引擎(唯一知道 viewBox 缩放比例的组件)来将其栅格化。
25
+ * 否则,各状态就必须知道 viewBox 的尺寸。
26
+ */
27
+ export interface ArcSpec {
28
+ id: string;
29
+ seed: ArcSeed;
30
+ t: number;
31
+ opacity: number;
32
+ }
33
+ export interface ArcRender {
34
+ id: string;
35
+ /** 身体前方的部分 */
36
+ front: string;
37
+ /** 身体后方的部分(先绘制,因此被剪影遮挡) */
38
+ back: string;
39
+ width: number;
40
+ opacity: number;
41
+ /** 沿轨迹的色相渐变 */
42
+ grad: {
43
+ x1: number;
44
+ y1: number;
45
+ x2: number;
46
+ y2: number;
47
+ stops: string[];
48
+ };
49
+ }
50
+ export interface ArcSeed {
51
+ /** 半长轴,以球半径为单位 */
52
+ a: number;
53
+ /** 扁率 b/a:实测 <= 0.45,轨道平面以侧视角度呈现 */
54
+ k: number;
55
+ /** 长轴在屏幕上的倾角,单位为弧度 */
56
+ tilt: number;
57
+ /** 每秒转数 */
58
+ speed: number;
59
+ phase: number;
60
+ /** 实际绘制的弧长占整圈的比例 */
61
+ sweep: number;
62
+ hue: number;
63
+ hueSpan: number;
64
+ width: number;
65
+ cx: number;
66
+ cy: number;
67
+ }
68
+ /**
69
+ * 将倾斜的 3D 圆以正交投影方式投射出来。
70
+ *
71
+ * 圆位于由 u(在屏幕内)和 v(伸入纵深方向)张成的平面上。z 分量用于把弧
72
+ * 切成两半:后半部分先于身体绘制,因此被它遮挡。正是这种真正的深度排序,
73
+ * 让圆环读起来像轨道,而不是一幅平面图画。
74
+ */
75
+ export declare function arcRender(seed: ArcSeed, t: number, scale: number, id: string, opacity?: number): ArcRender;
76
+ /**
77
+ * 6 个圆环,半长轴 1.30-1.40(因此明显大于球体),
78
+ * 扁率始终 <= 0.45,厚度 0.055,约 3.3 转/秒。
79
+ */
80
+ export declare const RINGS: ArcSeed[];
81
+ /**
82
+ * 一束嵌套的弧,在轨道之前扫过三角形。
83
+ * 几乎以侧视角度呈现(因此呈发夹形),rmax 为 1.37。
84
+ */
85
+ export declare const SWOOSH: ArcSeed[];
86
+ /** x 实测值:-0.557 / -0.013 / +0.532,y = 0。 */
87
+ export declare const DOT_X: readonly [-0.557, -0.013, 0.532];
88
+ export declare const DOT_R = 0.165;
89
+ export declare const DOT_PEAK = 1.25;
90
+ /**
91
+ * 粒子并非沿直线飞出:它们一边变大,一边向中心螺旋收缩
92
+ * (半径每帧乘 0.75,角度 +100 度/秒),最终转到核心后方被吞没。
93
+ */
94
+ export declare function particles(t: number, scale: number): DotRender[];
95
+ export declare const COMET_RIBBONS: ArcSeed[];
96
+ /** 彗星中心点的半径,实测为 0.129。 */
97
+ export declare const COMET_DOT = 0.129;
98
+ /** 从像素取样的蓝色。 */
99
+ export declare const NOTIF_BLUE = "#2496e8";
100
+ /** 小圆点恰好位于圆周上,在 -42deg 处。 */
101
+ export declare const NOTIF_ANGLE = -42;
102
+ export declare const NOTIF_DIST = 1.003;
103
+ /** 静止时的半径;弹出动画最高高出 14%。 */
104
+ export declare const NOTIF_R = 0.15;
105
+ export declare const NOTIF_POP = 1.14;
106
+ /**
107
+ * 凹槽是一个与小圆点同心的圆盘,从身体上减去。
108
+ * 边距是恒定的(0.054 R),并随身体的缩放而变化。
109
+ */
110
+ export declare const NOTIF_MARGIN = 0.054;
@@ -0,0 +1,189 @@
1
+ import { type ArcRender, type DotRender } from './decor';
2
+ import { type BotExpression } from './expressions';
3
+ import { type StateId } from './states';
4
+ export interface RenderedEye {
5
+ d: string;
6
+ matrix: string;
7
+ alpha: number;
8
+ }
9
+ export interface BotFrame {
10
+ bodyPath: string;
11
+ bodyAlpha: number;
12
+ eyes: RenderedEye[];
13
+ /** 嘴巴,像眼睛一样在身体上开洞;null = 没有嘴巴 */
14
+ mouth: RenderedEye | null;
15
+ dots: DotRender[];
16
+ /** true = 点状装饰移到身体后面(爆裂粒子) */
17
+ dotsBehind: boolean;
18
+ arcs: ArcRender[];
19
+ notif: {
20
+ x: number;
21
+ y: number;
22
+ r: number;
23
+ } | null;
24
+ notch: {
25
+ x: number;
26
+ y: number;
27
+ r: number;
28
+ } | null;
29
+ }
30
+ /**
31
+ * 当外部事物驱动机器人时,它看向哪里——目前是鼠标指针。
32
+ *
33
+ * `yaw` 和 `pitch` 是绝对方向,随着 `mix` 升高,它们取代姿态中的方向。有两个原因,
34
+ * 每个都是已经踩过的坑:
35
+ *
36
+ * - 这种混合必须由引擎完成,而不是调用方,因为只有引擎才知道当前这一刻的姿态。
37
+ * 如果调用方去补偿表情的朝向,它会在过渡仍在进行时读到目标值,于是每次情绪变化
38
+ * 眼睛都会跳动;
39
+ * - 而且两个轴上都必须是绝对的。如果使用相对值,眼睛的高度会跟随每个表情而变化
40
+ * ——「中性」看向 +28.6deg,而其他表情介于 -9 到 +9 之间——于是第一次情绪变化时
41
+ * 眼睛会骤然下坠。在跟随过程中决定表情性格的,是它眼睛的形状(眯起、圆睁、不对称),
42
+ * 而不是它看向哪里:看向哪里,由光标来决定。
43
+ *
44
+ * `mix` 表示外部对方向的掌控程度(0 = 完全没有)。
45
+ *
46
+ * `wander` 则单独表示剩余的自动漂移。两者不能混淆:当指针移动时,漂移应当熄灭——
47
+ * 如果叠加,机器人看起来就像在寻找光标却永远抓不住它。但当没有指针时(例如切到
48
+ * 键盘、触屏,或鼠标移出窗口),头部应当保持转向并继续存活。把两者混淆会让视线
49
+ * 在视图一打开时就僵住。
50
+ *
51
+ * `spin` 是途中要转过的圈数,单位为度,会随着到达而逐渐融化为 0。由于眼睛生活在一个
52
+ * 球面上,转一圈会让它们绕到球体背面再从另一侧回来——而 `-360deg` 和 `0` 是同一个
53
+ * 角度,因此不会改变它们最终落下的位置。
54
+ */
55
+ export interface Look {
56
+ yaw: number;
57
+ pitch: number;
58
+ mix: number;
59
+ spin: number;
60
+ wander: number;
61
+ }
62
+ /**
63
+ * 无时钟引擎:`sample(t)` 是时间的纯函数。
64
+ *
65
+ * 实际后果:暂停、恢复、减速以及跳到任意时间点都会得到完全相同的画面,
66
+ * 而且渲染无需 DOM 即可测试。
67
+ */
68
+ export declare class BotEngine {
69
+ /** 静止时球体的半径,以 viewBox 为单位 */
70
+ readonly scale: number;
71
+ private cur;
72
+ private prev;
73
+ /**
74
+ * 被冻结的起始姿态,仅当状态变化发生在一次过渡仍在进行时才会设置。
75
+ * 参见 `setState`。
76
+ */
77
+ private departFige;
78
+ private tCur;
79
+ private tPrev;
80
+ private blinkAt;
81
+ private pts;
82
+ private shape;
83
+ private shapePrev;
84
+ private shapeAt;
85
+ private expr;
86
+ private exprPrev;
87
+ private exprAt;
88
+ private look;
89
+ private lookPrev;
90
+ private lookAt;
91
+ /** 当前追赶过渡的时长;见 `LOOK_MORPH`,它的默认值 */
92
+ private lookMorph;
93
+ /** 改变身体形状时的过渡时长 */
94
+ static readonly SHAPE_MORPH = 0.45;
95
+ /**
96
+ * 视线追赶目标的时长。比 `SHAPE_MORPH` 更短:一个跟随的视线应当显得专注,
97
+ * 而不是黏滞。由于目标在每次鼠标移动时都会被重新设定,正是这个时长赋予了
98
+ * 跟随的惯性——视线永远不会完全追上一个正在移动的光标。
99
+ */
100
+ static readonly LOOK_MORPH = 0.24;
101
+ constructor(scale?: number, initial?: StateId, shape?: number[] | null, expression?: BotExpression | null);
102
+ /**
103
+ * 在个性化设置中选择的静止表情。和形状一样,它会滑向新值而不是直接跳变。
104
+ */
105
+ setExpression(expression: BotExpression | null, now?: number): void;
106
+ /** 在 `now` 时刻生效的表情,包含正在进行的过渡。 */
107
+ private exprAtTime;
108
+ /**
109
+ * 在个性化设置中选择的形状。它只会在静止状态(`baseBody`)下替换身体:
110
+ * 在其他状态下,轮廓本身就是动画,不能被覆盖。
111
+ *
112
+ * 变化以过渡方式进行,而不是瞬间完成:由于所有形状都在相同的角度上采样,
113
+ * 只需要对半径进行插值即可。
114
+ */
115
+ setShape(radii: number[] | null, now?: number): void;
116
+ /**
117
+ * 在 `now` 时刻生效的形状,包含正在进行的过渡。
118
+ *
119
+ * 过渡结束时不会把 `shapePrev` 置为 null:`sample` 必须保持为时间的纯函数,
120
+ * 因此重新读取一个过去的时刻必须得到中间画面。这里只是多保留了一个引用。
121
+ */
122
+ private shapeAtTime;
123
+ /**
124
+ * 新的视线目标,传入 `null` 表示回到该状态自身的视线。
125
+ *
126
+ * 它从当前值出发,而不是像 `setShape` 那样从上一个目标出发:这个方法在每次
127
+ * 指针移动时都会被调用,如果从上个目标出发,就会在每次追赶之前让视线倒退一步
128
+ * ——跟随会抖动而不是平滑滑动。
129
+ *
130
+ * 除此之外与 `setShape` 约定相同:外部状态通过带时间戳的 setter 进入,
131
+ * 绝不通过 `sample` 期间读取的变量,否则引擎就不再是时间的纯函数。
132
+ */
133
+ setLook(look: Look | null, now: number, morph?: number): void;
134
+ /** 在 `now` 时刻生效的视线,包含正在进行的追赶。 */
135
+ private lookAtTime;
136
+ private posed;
137
+ /**
138
+ * 在 `now` 时刻、给定状态下的眼睛偏移,以球体半径为单位。
139
+ *
140
+ * 它是从表中读取并插值得到的,绝不重新计算:`eyefit.ts` 解释了为什么这种区分
141
+ * 就是全部修正。这里剩下的只是沿着形状轴去插值它,使用与轮廓过渡完全相同的曲线
142
+ * 和时长——因为是同一个原因,所以必须是同一种运动。
143
+ *
144
+ * 我们在过渡的两端(`shapePrev` 和 `shape`)上查询表格,而不是查询 `shapeAtTime`
145
+ * 返回的轮廓:后者是每一帧新分配的数组,没有身份标识,任何表格里都不存在它。
146
+ */
147
+ private decalageAtTime;
148
+ get state(): StateId;
149
+ /**
150
+ * 不带任何先前状态、直接回到 `id`,就像一台全新的引擎落在这个状态上。
151
+ *
152
+ * 这就是对这个引擎而言「倒带」的含义。仅靠 `setState` 无法做到这一点:
153
+ * 它会保留被离开的状态用于融合,这在回放时正是它的职责,但在回到序列开头时
154
+ * 恰恰是错误的行为。在完整跑完一遍后重放第 0 帧,会把第一个状态与最后一个状态
155
+ * 混合起来,GIF 导出就会以一颗没有眼睛的球开场——彗星状态的 `eyeAlpha` 为零。
156
+ *
157
+ * `sample` 仍然是时间的纯函数:和 `setState` 一样,这是一个带时间戳的 setter,
158
+ * 由序列驱动器调用,绝不会在采样期间调用。
159
+ */
160
+ reset(id: StateId, now: number): void;
161
+ /**
162
+ * 当前过渡的起点:如果有被冻结的姿态就用它,否则用被离开的状态按其自身已流逝的
163
+ * 时间求值——因此它仍在动画中,这正是期望的行为。
164
+ */
165
+ private origine;
166
+ /**
167
+ * 在 `now` 时刻的合成姿态,包含正在进行的过渡:正是 `sample` 在叠加静止生命
168
+ * 与视线层之前所混合的内容。抽离出来是为了让 `setState` 能够冻结它。
169
+ */
170
+ private poseComposee;
171
+ /**
172
+ * 状态变化,带时间戳。
173
+ *
174
+ * 引擎只保留一格历史,因此在过渡进行中发生的状态变化,会把混合起点替换为被离开
175
+ * 状态的完整姿态,而不是屏幕上那幅部分混合的画面。以 100 ms 间隔测量
176
+ * `idle -> wide -> idle`:跳变 35.9 px,而正常运动只有 8.0 px。
177
+ *
178
+ * 因此我们把当前合成姿态冻结起来,从它开始混合。无论连续发生多少次状态变化,
179
+ * 这样在构造上都是连续的。
180
+ *
181
+ * 而且只有在这种情况下才冻结。每次变化都冻结,会让被离开的状态在整个过渡期间
182
+ * 戛然停住——`alert` 的「!」会在行进途中僵住——而在非过渡时并没有需要修正的
183
+ * 东西:被离开的状态此时恰好就是屏幕上显示的画面。播放一段剪辑时,其各块时长
184
+ * 至少不短于最长的过渡(`MIN_BLOCK`),因此从不会冻结任何东西,输出与原先逐位
185
+ * 一致。
186
+ */
187
+ setState(id: StateId, now: number): void;
188
+ sample(now: number): BotFrame;
189
+ }
@@ -0,0 +1,36 @@
1
+ import { type HeadGaze } from './face';
2
+ import type { EyeCfg, MouthCfg } from './states';
3
+ /**
4
+ * 机器人的静止表情。
5
+ *
6
+ * 脸只靠两颗胶囊立住,因此一切都围绕四个杠杆展开:
7
+ * 头部的朝向、眼睛的间距、它们的比例,以及
8
+ * 每只眼睛自身的倾斜。正是这最后一个造就了愤怒与
9
+ * 悲伤:它们要求镜像的倾斜(上端
10
+ * 向内聚拢或向外张开),仅靠头部滚转无法实现,因为那会让
11
+ * 两只眼睛朝同一侧倾斜。
12
+ *
13
+ * 只有静止状态承载这个表情。视频中的表情状态
14
+ * (眨眼、睁大眼睛、通知)保留它们自己的:我们来这里正是为了
15
+ * 复现它。
16
+ *
17
+ * 这些幅度参考 bible-strong-avatar-lab,它暴露了相同的
18
+ * 模型(头部 X/Y/Z、每只眼睛的宽度和高度、间距、每只眼睛的角度):在它们
19
+ * 那里宽度从中性的 0.8 倍到 2.7 倍,高度从 0.3 到 1.5,
20
+ * 角度最大到 ±80°。我们保持在这个范围内。
21
+ */
22
+ /** 枚举类型,使 i18n 层能在编译期校验它们的翻译。 */
23
+ export type ExpressionId = 'neutre' | 'attentif' | 'surpris' | 'excite' | 'heureux' | 'hilare' | 'colere' | 'triste' | 'effraye' | 'mefiant' | 'confus' | 'curieux' | 'fier' | 'timide' | 'blase' | 'somnolent' | 'admiratif' | 'stupefait' | 'sourire' | 'regardHaut' | 'regardBas' | 'regardGauche' | 'regardDroite' | 'regardHautGauche' | 'regardHautDroite' | 'regardBasGauche' | 'regardBasDroite' | 'regardArriereHaut' | 'regardArriereBas' | 'regardArriereGauche' | 'regardArriereDroite';
24
+ export interface BotExpression {
25
+ id: ExpressionId;
26
+ gaze: HeadGaze;
27
+ split: number;
28
+ eyes: [EyeCfg, EyeCfg];
29
+ /** 可选的嘴巴:只有携带嘴巴的表情才有它。 */
30
+ mouth?: MouthCfg;
31
+ }
32
+ export declare const EXPRESSIONS: BotExpression[];
33
+ export declare const EXPRESSION_BY_ID: Map<string, BotExpression>;
34
+ export declare const DEFAULT_EXPRESSION = "neutre";
35
+ /** 两种表情之间的插值:变化以平滑过渡的方式进行。 */
36
+ export declare function blendExpression(a: BotExpression, b: BotExpression, t: number): BotExpression;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 在个性化器的形状上放置脸部。
3
+ *
4
+ * 眼睛生活在一个球面上,而 `radiusAtAngle` 会按局部半径的比例
5
+ * 把它们贴回到真实轮廓上。这个比例确实放对了它们的中心,但眼睛本身有尺寸:
6
+ * 它前方离边缘所剩的边距被乘上了相同的因子,因此在它方向上较窄的
7
+ * 轮廓会把它推向边缘,直到
8
+ * 蒙版将它向外打开。胶囊在 `capsule`、`triangle`、`nuage` 和 `goutte` 上
9
+ * 会表现为身体上的一个凹槽。
10
+ *
11
+ * 这个模块在加载时一次性解决问题,并返回一张偏移量表。
12
+ * 这个选择才是修复的核心,远胜过随后介绍的几何:
13
+ *
14
+ * 如果在渲染循环中求解,修正会响应每一秒六十帧里
15
+ * 一切运动的东西——视线的漂移、指针、正在 morph 的
16
+ * 表达式、最近边缘的变化、最受约束眼睛的变化。用这种方式写了
17
+ * 七种变体,全都产生了可见的运动伪影:
18
+ * 持续的颤抖、参考边缘切换时 26 个单位的方向跳变、
19
+ * 尺寸参与计算时的骤然放大。
20
+ * 缺陷不在于它们任何一个的几何,而在于逐帧求解这件事本身。
21
+ *
22
+ * 引擎的其余部分并不是这样工作的:姿态是声明的,它只是
23
+ * 用已知的曲线在它们之间插值。一张查表得到的偏移量符合这个模式。
24
+ * 它不会在视线漂移或指针移动时变化,而在形状
25
+ * 或表达式变化时,它只是沿着该 morph 的曲线
26
+ * 从表中的一个条目走到另一个。颤抖从构造上变得不可能,而不是被压制:
27
+ * 在两个常量之间插值是单调的,而在正在插值的视线上重新求解
28
+ * 问题则不是单调的。
29
+ *
30
+ * 令人愉快的一个推论:求解器不再有任何连续性约束,因为它
31
+ * 在动画期间不运行。因此它可以探测一整束方向,
32
+ * 覆盖视线漂移的最坏情况,这是逐帧版本所
33
+ * 无法承担的。
34
+ *
35
+ * 这张表是模块常量,在 import 时由纯数据构建:
36
+ * 性质与 `face.ts` 的眨眼时刻表相同,确定性的、无状态的,
37
+ * 因此不影响 `engine.sample(t)` 的纯性。
38
+ */
39
+ import { type StateId } from './states';
40
+ /**
41
+ * 偏移量表,在 import 时构建:每个(形状、带基础身体的状态、
42
+ * 表达式)一个条目。只有 `idle` 和 `swirl` 承载静止脸部,因此只有它们
43
+ * 会按表达式细分——另外三个带基础身体的状态在视频中有一张
44
+ * 抬起的脸,只有一个条目。
45
+ *
46
+ * 通过半径数组的引用来作为键,这已经是引擎的约定:它的
47
+ * 守卫 `radii === this.shape` 和 `expression === this.expr` 依赖同样的
48
+ * 稳定性。一个未知的轮廓,或 `null`,不做任何修正——API 接受任意
49
+ * 数组,引擎不必依赖调用者的谨慎。
50
+ */
51
+ declare function batir(): Map<number[], Map<string, {
52
+ x: number;
53
+ y: number;
54
+ }>>;
55
+ /**
56
+ * 对于这个状态下的这个形状,要施加在两只眼睛上的偏移量,单位为球的
57
+ * 半径——引擎会把它缩放到自己的比例。
58
+ *
59
+ * 只要形状不在目录中就为零,这涵盖了 `null` 和圆:
60
+ * 在圆上两个轮廓是相同的,因此边距已经是要求的边距,
61
+ * 下降在第一轮就退出。因此视频中记录的形状不会移动,
62
+ * 无需特殊情况。
63
+ */
64
+ export declare function decalageDesYeux(radii: number[] | null, state: StateId, expr: string | null): {
65
+ x: number;
66
+ y: number;
67
+ };
68
+ /** 供测试使用:用来在不重做几何的情况下验证这张表。 */
69
+ /** 供测试使用:用来计时这张表的构建。 */
70
+ export declare const POUR_TESTS: {
71
+ batir: typeof batir;
72
+ };
73
+ export {};
@@ -0,0 +1,76 @@
1
+ /** 眼睛在球面上的半张角,单位为度(总间距约 31 度)。 */
2
+ export declare const EYE_SPLIT = 15.46;
3
+ /** 静止状态下眼睛的尺寸,单位为球的半径。 */
4
+ export declare const EYE_W = 0.186;
5
+ export declare const EYE_H = 0.412;
6
+ /** 静止状态下的头部朝向,依据参考帧拟合得出。 */
7
+ export declare const REST_GAZE: HeadGaze;
8
+ export interface EyePose {
9
+ x: number;
10
+ y: number;
11
+ /** 2x2 切向矩阵:[a b c d],含义同 SVG 的 matrix(a,b,c,d,e,f) */
12
+ a: number;
13
+ b: number;
14
+ c: number;
15
+ d: number;
16
+ /** 法线的 z 分量:> 0 表示该面可见 */
17
+ depth: number;
18
+ }
19
+ export interface HeadGaze {
20
+ /** 偏航角,单位为度,正值表示向右看 */
21
+ yaw: number;
22
+ /** 俯仰角,单位为度,正值表示向上看 */
23
+ pitch: number;
24
+ /** 滚转角,单位为度,表示头部倾斜 */
25
+ roll: number;
26
+ }
27
+ /**
28
+ * 先是头部的坐标系,然后是两只眼睛的坐标系。
29
+ * 屏幕坐标系:x 向右,y 向下,z 指向观察者。
30
+ * 索引 0 是内侧眼,索引 1 是外侧眼。
31
+ */
32
+ export declare function eyePoses(gaze: HeadGaze, scale: number, split?: number): [EyePose, EyePose];
33
+ /**
34
+ * 嘴巴落在球面上时位于眼睛下方的角度,单位为度。
35
+ *
36
+ * 这是选择出来的,而非测量出来的:参考视频中没有嘴巴。
37
+ * 只要它保持在球面的前侧并位于眼睛下方即可,也就是处于
38
+ * 与向下看相同的范围内。
39
+ */
40
+ export declare const MOUTH_DOWN = 28;
41
+ /**
42
+ * 嘴巴在球面上的位置,采用与眼睛相同的正交投影:
43
+ * forward 向下旋转 `MOUTH_DOWN` 度,宽度沿 `right` 方向,
44
+ * 高度沿 `down` 方向。只有一个点,而不是两个。
45
+ */
46
+ export declare function mouthPose(gaze: HeadGaze, scale: number): EyePose;
47
+ /**
48
+ * 静止状态下的生命力:视线的缓慢漂移、扫视、眨眼。
49
+ *
50
+ * 时间的纯函数(没有内部状态),因此暂停、恢复以及跳到
51
+ * 任意时间点都会得到相同的画面。这些数值是要
52
+ * 加到当前状态姿态上的偏移量。
53
+ */
54
+ export interface Liveliness {
55
+ dYaw: number;
56
+ dPitch: number;
57
+ dRoll: number;
58
+ /** 1 = 眼睛睁开,0 = 闭合(屏幕坐标系中的垂直压扁) */
59
+ lid: number;
60
+ driftX: number;
61
+ driftY: number;
62
+ breath: number;
63
+ }
64
+ export interface LivelinessOptions {
65
+ wander?: number;
66
+ blink?: boolean;
67
+ float?: boolean;
68
+ }
69
+ export declare function liveliness(t: number, opt?: LivelinessOptions): Liveliness;
70
+ /**
71
+ * 眨眼是屏幕坐标系中围绕眼睛中心的垂直压扁
72
+ * (测量结果:bbox 的宽度保持不变,高度降到约 0.35),
73
+ * 而不是沿胶囊倾斜轴的收缩。因此我们在切向矩阵之后
74
+ * 再合成它,只影响 y 方向上的输出。
75
+ */
76
+ export declare function blinkScale(lid: number): number;
@@ -0,0 +1,21 @@
1
+ export declare const TAU: number;
2
+ export declare const clamp: (v: number, lo?: number, hi?: number) => number;
3
+ export declare const lerp: (a: number, b: number, t: number) => number;
4
+ export type Easing = (t: number) => number;
5
+ /**
6
+ * 视频上的测量结果:过渡都是指数型的 ease-out,不会超出
7
+ * 身体范围。仅有的弹簧效果都是局部的(通知圆点的弹出、
8
+ * 眼睛的睁开),并且直接写在
9
+ * 相关的状态中。
10
+ */
11
+ export declare const easings: {
12
+ easeOutCubic: (t: number) => number;
13
+ easeInOutCubic: (t: number) => number;
14
+ easeOutQuint: (t: number) => number;
15
+ };
16
+ /** 周期性一维噪声:在 `period` 上无缝循环,用于视线的漂移。 */
17
+ export declare function loopNoise(t: number, period: number, seed?: number): number;
18
+ /** 确定性 PRNG(mulberry32):每次读取都产生相同的序列。 */
19
+ export declare function createRng(seed: number): () => number;
20
+ /** 短舍入:将 60 fps 下生成的 path 字符串的体积大约减半。 */
21
+ export declare const r2: (v: number) => number;
@@ -0,0 +1,7 @@
1
+ export declare const PROFILE_SAMPLES = 64;
2
+ export declare const PROFILES: {
3
+ readonly egg: readonly [0.8369, 0.8424, 0.8497, 0.8585, 0.8674, 0.8775, 0.8878, 0.8983, 0.9089, 0.9185, 0.9288, 0.9374, 0.9445, 0.9504, 0.9543, 0.9559, 0.9555, 0.9519, 0.9466, 0.9389, 0.9302, 0.9193, 0.9085, 0.8969, 0.8852, 0.8734, 0.8625, 0.8513, 0.8411, 0.8325, 0.8243, 0.8179, 0.8137, 0.8112, 0.8102, 0.8128, 0.8178, 0.8262, 0.8374, 0.8518, 0.8702, 0.8922, 0.9169, 0.9446, 0.9741, 1.0023, 1.0267, 1.0433, 1.0481, 1.0393, 1.0216, 0.997, 0.9697, 0.9418, 0.9169, 0.8949, 0.876, 0.8604, 0.849, 0.8394, 0.8337, 0.8314, 0.8305, 0.8326];
4
+ readonly hexagon: readonly [0.921, 0.9282, 0.9441, 0.9706, 0.9984, 1.0059, 0.9896, 0.9562, 0.929, 0.9124, 0.9047, 0.9058, 0.9157, 0.9349, 0.9642, 0.9873, 0.9882, 0.9665, 0.9336, 0.9105, 0.8968, 0.8918, 0.8955, 0.908, 0.9293, 0.9611, 0.982, 0.9812, 0.959, 0.9282, 0.9089, 0.8978, 0.8964, 0.9026, 0.9189, 0.9439, 0.9778, 0.999, 0.9964, 0.9713, 0.9439, 0.9274, 0.9196, 0.9206, 0.9308, 0.9502, 0.9799, 1.0121, 1.0226, 1.0071, 0.9752, 0.951, 0.9366, 0.9316, 0.9351, 0.9485, 0.9711, 1.0026, 1.0213, 1.0155, 0.9863, 0.9547, 0.9347, 0.9232];
5
+ readonly triangle: readonly [0.7819, 0.8211, 0.8747, 0.944, 1.0223, 1.096, 1.1401, 1.134, 1.0808, 1.0047, 0.9265, 0.8603, 0.8104, 0.773, 0.745, 0.7273, 0.7151, 0.7118, 0.7148, 0.7245, 0.7427, 0.768, 0.8037, 0.8518, 0.9148, 0.9876, 1.0583, 1.1073, 1.1109, 1.0667, 0.994, 0.9164, 0.8482, 0.7948, 0.7555, 0.7261, 0.7056, 0.6925, 0.6859, 0.6869, 0.6938, 0.7084, 0.7305, 0.7615, 0.804, 0.8595, 0.9311, 1.0092, 1.0791, 1.1171, 1.1054, 1.0501, 0.9779, 0.905, 0.845, 0.799, 0.7656, 0.7413, 0.7258, 0.716, 0.7146, 0.7204, 0.733, 0.7528];
6
+ };
7
+ export type ProfileName = keyof typeof PROFILES;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 引擎所渲染一切内容的基准。
3
+ *
4
+ * `engine.sample()` 输出以 viewBox 为单位的坐标,而这两个数字就是它的定义:
5
+ * 没有它们,引擎的输出就没有任何意义。它们原本住在 `BloubBot.vue` 里,因此无法被引用——
6
+ * 一个 `<script setup>` 不导出任何东西——而 `export.ts` 又手动重复定义了其中一个,
7
+ * 并附上了指明该问题的注释。
8
+ *
9
+ * 它们之所以在这里,是因为 `src/bot/` 是供外部读取和消费的:Vue 组件只是引擎的
10
+ * 一个客户端,而不是它的定义。
11
+ */
12
+ /**
13
+ * 球体静止时的半径,以 viewBox 为单位。它就是组件传给 `BotEngine` 的 `scale`。
14
+ *
15
+ * 这是选定值而非测量值:它是工作单位。目录中其余所有内容都以该半径的分数来表示,
16
+ * 这使从视频测得的数值与显示尺寸无关。
17
+ */
18
+ export declare const RAYON = 100;
19
+ /**
20
+ * 所显示 viewBox 的半边长。半径之外的边距用于容纳圆环。
21
+ *
22
+ * 这不是一个随意值:轨道的圆环和彗星的 swoosh 最高到达半径的 1.4 倍,即 140。
23
+ * 运行时不设任何限制——正是对 `RINGS` 和 `SWOOSH`(`decor.ts`)数组的手动调整
24
+ * 让它们保持在 158 以内,并且有一个测试来锁定这一点。
25
+ */
26
+ export declare const DEMI_VIEWBOX = 158;