@springbrand/message-panel 0.1.3-alpha.8 → 0.2.0-alpha.37
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.
- package/cloud-os/README.md +4 -3
- package/cloud-os/assets/followup-arrow.svg +3 -0
- package/cloud-os/assets/loading-corner.svg +3 -0
- package/cloud-os/assets/loading-mark.svg +16 -0
- package/cloud-os/assets/loading-spark.svg +3 -0
- package/cloud-os/assets/model-selected.svg +5 -0
- package/cloud-os/assets/thinking-star.svg +9 -0
- package/cloud-os/capability-chip.tsx +1 -1
- package/cloud-os/chat/cloud-os-chat-messages.tsx +441 -98
- package/cloud-os/chat/code-runner.tsx +106 -0
- package/cloud-os/chat/image-generation.tsx +76 -0
- package/cloud-os/chat/loading-state.tsx +25 -0
- package/cloud-os/chat/markdown-message.tsx +144 -41
- package/cloud-os/chat/range.ts +8 -0
- package/cloud-os/chat/rich-blocks.tsx +285 -227
- package/cloud-os/chat/surfaces.tsx +90 -0
- package/cloud-os/chat/tool-call.tsx +94 -0
- package/cloud-os/chat/tool-presentation.ts +31 -22
- package/cloud-os/chat/tool-rows.tsx +195 -143
- package/cloud-os/chat/tool-timeline.tsx +123 -0
- package/cloud-os/chat/transcript-model.ts +258 -20
- package/cloud-os/chat/web-search.tsx +135 -0
- package/cloud-os/composer/cloud-os-chat-input.tsx +38 -88
- package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
- package/cloud-os/file-view.tsx +195 -12
- package/cloud-os/index.ts +24 -3
- package/cloud-os/layout/cloud-os-workspace-split.tsx +60 -3
- package/cloud-os/primitives/collapsible.tsx +21 -0
- package/cloud-os/primitives/workshop-controls.tsx +2 -2
- package/cloud-os/styles/cloud-os.css +132 -1
- package/demo/camel-chat-showcase.tsx +21 -13
- package/demo/chat-scenarios.ts +214 -40
- package/demo/cloud-os-chat-showcase.tsx +37 -14
- package/demo/fixtures.ts +4 -5
- package/demo/message-panel-gallery.tsx +16 -2
- package/package.json +8 -4
- package/springbrand-pet/LICENSE.bloub +21 -0
- package/springbrand-pet/bot/cycles.test.ts +221 -0
- package/springbrand-pet/bot/cycles.ts +225 -0
- package/springbrand-pet/bot/decor.ts +285 -0
- package/springbrand-pet/bot/engine.test.ts +543 -0
- package/springbrand-pet/bot/engine.ts +558 -0
- package/springbrand-pet/bot/expressions.test.ts +135 -0
- package/springbrand-pet/bot/expressions.ts +192 -0
- package/springbrand-pet/bot/eyefit.ts +455 -0
- package/springbrand-pet/bot/face.test.ts +101 -0
- package/springbrand-pet/bot/face.ts +179 -0
- package/springbrand-pet/bot/math.ts +42 -0
- package/springbrand-pet/bot/profiles.ts +22 -0
- package/springbrand-pet/bot/repere.ts +31 -0
- package/springbrand-pet/bot/shape.test.ts +97 -0
- package/springbrand-pet/bot/shape.ts +310 -0
- package/springbrand-pet/bot/skins.test.ts +325 -0
- package/springbrand-pet/bot/skins.ts +161 -0
- package/springbrand-pet/bot/states.ts +616 -0
- package/springbrand-pet/drag.test.ts +19 -0
- package/springbrand-pet/index.tsx +569 -0
- package/springbrand-pet/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/springbrand-pet/vitest.config.ts +9 -0
- package/src/camel/camel-chat-messages.tsx +78 -78
- package/src/camel/camel-tool-presentation.tsx +19 -15
- package/src/camel/camel-turn.ts +1 -17
- package/src/composer/composer-attachments.tsx +1 -1
- package/src/composer/composer.tsx +2 -2
- package/src/contracts.ts +0 -2
- package/src/message-panel.tsx +1 -24
- package/src/parts/index.tsx +103 -64
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COMET_DOT,
|
|
3
|
+
COMET_RIBBONS,
|
|
4
|
+
DOT_PEAK,
|
|
5
|
+
DOT_R,
|
|
6
|
+
DOT_X,
|
|
7
|
+
NOTIF_ANGLE,
|
|
8
|
+
NOTIF_DIST,
|
|
9
|
+
NOTIF_MARGIN,
|
|
10
|
+
NOTIF_POP,
|
|
11
|
+
NOTIF_R,
|
|
12
|
+
RINGS,
|
|
13
|
+
SWOOSH,
|
|
14
|
+
particles,
|
|
15
|
+
type ArcSpec,
|
|
16
|
+
type DotRender
|
|
17
|
+
} from './decor'
|
|
18
|
+
import { EYE_H, EYE_SPLIT, EYE_W, REST_GAZE, type HeadGaze } from './face'
|
|
19
|
+
import { TAU, clamp, easings } from './math'
|
|
20
|
+
import {
|
|
21
|
+
circle,
|
|
22
|
+
hullOfCircles,
|
|
23
|
+
polyPath,
|
|
24
|
+
profileFromPolygon,
|
|
25
|
+
silhouette,
|
|
26
|
+
type Silhouette
|
|
27
|
+
} from './shape'
|
|
28
|
+
|
|
29
|
+
export interface EyeCfg {
|
|
30
|
+
/** largeur locale (axe court de la gelule), en unites de rayon de boule */
|
|
31
|
+
w: number
|
|
32
|
+
/** hauteur locale (axe long) */
|
|
33
|
+
h: number
|
|
34
|
+
/** 1 = ouvert, 0 = ferme */
|
|
35
|
+
open: number
|
|
36
|
+
/**
|
|
37
|
+
* Inclinaison propre de la gelule, en degres, positif = le haut part a
|
|
38
|
+
* droite. Appliquee APRES le repere tangent de la sphere. Sans elle, les deux
|
|
39
|
+
* yeux penchent forcement du meme cote (le roulis de tete) et la colere comme
|
|
40
|
+
* la tristesse, qui demandent des inclinaisons en miroir, sont hors de portee.
|
|
41
|
+
*/
|
|
42
|
+
tilt?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Pose {
|
|
46
|
+
/** silhouette du corps, en unites de rayon de boule */
|
|
47
|
+
sil: Silhouette
|
|
48
|
+
/** decalage global du corps ET des yeux */
|
|
49
|
+
offX: number
|
|
50
|
+
offY: number
|
|
51
|
+
gaze: HeadGaze
|
|
52
|
+
/** demi-ecart des yeux sur la sphere, en degres */
|
|
53
|
+
split: number
|
|
54
|
+
/** [oeil interieur, oeil exterieur] */
|
|
55
|
+
eyes: [EyeCfg, EyeCfg]
|
|
56
|
+
/** opacite des yeux : sert aux etats sans visage */
|
|
57
|
+
eyeAlpha: number
|
|
58
|
+
bodyAlpha: number
|
|
59
|
+
dots: DotRender[]
|
|
60
|
+
arcs: ArcSpec[]
|
|
61
|
+
notif: { x: number; y: number; r: number; notch: number } | null
|
|
62
|
+
/** true = le decor passe derriere le corps (particules de l'eclatement) */
|
|
63
|
+
dotsBehind: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const pair = (w: number, h: number): [EyeCfg, EyeCfg] => [
|
|
67
|
+
{ w, h, open: 1 },
|
|
68
|
+
{ w, h, open: 1 }
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
function base(over: Partial<Pose> = {}): Pose {
|
|
72
|
+
return {
|
|
73
|
+
sil: circle(1),
|
|
74
|
+
offX: 0,
|
|
75
|
+
offY: 0,
|
|
76
|
+
gaze: { ...REST_GAZE },
|
|
77
|
+
split: EYE_SPLIT,
|
|
78
|
+
eyes: pair(EYE_W, EYE_H),
|
|
79
|
+
eyeAlpha: 1,
|
|
80
|
+
bodyAlpha: 1,
|
|
81
|
+
dots: [],
|
|
82
|
+
arcs: [],
|
|
83
|
+
notif: null,
|
|
84
|
+
dotsBehind: false,
|
|
85
|
+
...over
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* --------------------------------------------------- formes non radiales */
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Barre du "!" vertical : enveloppe convexe de deux cercles.
|
|
93
|
+
* Mesure : cercle haut (0, -0.505) r 0.132, cercle bas (0, +0.130) r 0.075,
|
|
94
|
+
* flancs rectilignes. Elle est donc tronconique (rapport haut/bas 1.76).
|
|
95
|
+
*/
|
|
96
|
+
const BAR_UPRIGHT_CY = -0.1875
|
|
97
|
+
const BAR_UPRIGHT = profileFromPolygon(
|
|
98
|
+
hullOfCircles(0, -0.505, 0.132, 0, 0.13, 0.075),
|
|
99
|
+
0,
|
|
100
|
+
BAR_UPRIGHT_CY
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
/** Barre du "!" penche : capsule pure (largeur constante 0.269, longueur 0.776). */
|
|
104
|
+
const BAR_ITALIC = profileFromPolygon(hullOfCircles(0, -0.2535, 0.1345, 0, 0.2535, 0.1345), 0, 0)
|
|
105
|
+
|
|
106
|
+
const barUpright = (pose: Partial<Silhouette> = {}): Silhouette => ({
|
|
107
|
+
radii: [...BAR_UPRIGHT],
|
|
108
|
+
rot: 0,
|
|
109
|
+
cx: 0,
|
|
110
|
+
cy: BAR_UPRIGHT_CY,
|
|
111
|
+
sx: 1,
|
|
112
|
+
sy: 1,
|
|
113
|
+
...pose
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const barItalic = (pose: Partial<Silhouette> = {}): Silhouette => ({
|
|
117
|
+
radii: [...BAR_ITALIC],
|
|
118
|
+
rot: 0,
|
|
119
|
+
cx: 0,
|
|
120
|
+
cy: 0,
|
|
121
|
+
sx: 1,
|
|
122
|
+
sy: 1,
|
|
123
|
+
...pose
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Le point du "!" penche n'est pas un disque : c'est une goutte, bout rond
|
|
128
|
+
* (r 0.118) du cote de la barre et pointe effilee a l'oppose, longueur 0.300
|
|
129
|
+
* dans l'axe du glyphe. Centree sur le barycentre du bout rond.
|
|
130
|
+
*/
|
|
131
|
+
const TEAR = polyPath(hullOfCircles(0, 0, 0.118, 0, 0.172, 0.012))
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Le triangle ne tourne pas sur lui-meme : son centre decrit un cercle de
|
|
135
|
+
* rayon 0.213 autour de l'origine (mesure). C'est ce decalage qui donne
|
|
136
|
+
* l'impression qu'il bascule au lieu de pivoter sur place.
|
|
137
|
+
*/
|
|
138
|
+
const TRI_ORBIT = 0.213
|
|
139
|
+
|
|
140
|
+
function spinningTriangle(rot: number): Silhouette {
|
|
141
|
+
return silhouette('triangle', {
|
|
142
|
+
rot,
|
|
143
|
+
cx: -TRI_ORBIT * Math.sin(rot),
|
|
144
|
+
cy: TRI_ORBIT * Math.cos(rot)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ------------------------------------------------------------------ etats */
|
|
149
|
+
|
|
150
|
+
export type StateId =
|
|
151
|
+
| 'idle'
|
|
152
|
+
| 'thinking'
|
|
153
|
+
| 'wink'
|
|
154
|
+
| 'wide'
|
|
155
|
+
| 'alert'
|
|
156
|
+
| 'notify'
|
|
157
|
+
| 'exclaim'
|
|
158
|
+
| 'sleep'
|
|
159
|
+
| 'egg'
|
|
160
|
+
| 'hexagon'
|
|
161
|
+
| 'play'
|
|
162
|
+
| 'orbit'
|
|
163
|
+
| 'burst'
|
|
164
|
+
| 'comet'
|
|
165
|
+
/** transition d'interface, pas une animation du catalogue : hors `SEQUENCE` */
|
|
166
|
+
| 'swirl'
|
|
167
|
+
|
|
168
|
+
export interface StateDef {
|
|
169
|
+
id: StateId
|
|
170
|
+
/** duree de maintien quand la sequence complete est jouee */
|
|
171
|
+
duration: number
|
|
172
|
+
/**
|
|
173
|
+
* duree en dessous de laquelle l'animation est coupee avant d'aboutir : le
|
|
174
|
+
* "!" ne revient pas, le corps reste eclate. Elle se lit dans les constantes
|
|
175
|
+
* de `pose` ci-dessous, elle ne se choisit pas. Absente = l'etat ignore le
|
|
176
|
+
* temps ou boucle, n'importe quelle duree lui va (voir `MIN_BLOCK`).
|
|
177
|
+
*/
|
|
178
|
+
minDuration?: number
|
|
179
|
+
/** duree du morph d'entree */
|
|
180
|
+
morph: number
|
|
181
|
+
/** true = l'entree est masquee par un clignement, comme dans la video */
|
|
182
|
+
blinkIn: boolean
|
|
183
|
+
/**
|
|
184
|
+
* true = le corps est la silhouette "au repos", donc remplacable par la forme
|
|
185
|
+
* choisie dans le personnalisateur. Les etats qui dessinent leur propre forme
|
|
186
|
+
* (le "!", les points, l'oeuf, le triangle...) valent false : c'est cette forme
|
|
187
|
+
* la qui EST l'animation.
|
|
188
|
+
*/
|
|
189
|
+
baseBody: boolean
|
|
190
|
+
/**
|
|
191
|
+
* true = l'etat porte le visage "au repos", donc remplacable par l'expression
|
|
192
|
+
* choisie. Seul `idle` : les autres etats a visage ont une expression relevee
|
|
193
|
+
* sur la video, c'est precisement ce qu'on reproduit.
|
|
194
|
+
*/
|
|
195
|
+
baseFace: boolean
|
|
196
|
+
pose(local: number): Pose
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Onde de pulsation qui parcourt les trois points de gauche a droite. */
|
|
200
|
+
function dotPulse(t: number, index: number): number {
|
|
201
|
+
const p = ((((t - index * 0.5) / 1.5) % 1) + 1) % 1
|
|
202
|
+
const k = p < 0.5 ? 0.5 - 0.5 * Math.cos(p * TAU) : 0
|
|
203
|
+
return clamp(k * 2)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export const STATES: StateDef[] = [
|
|
207
|
+
{
|
|
208
|
+
id: 'idle',
|
|
209
|
+
duration: 2.4,
|
|
210
|
+
morph: 0.45,
|
|
211
|
+
blinkIn: false,
|
|
212
|
+
baseFace: true,
|
|
213
|
+
baseBody: true,
|
|
214
|
+
pose: () => base()
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
{
|
|
218
|
+
id: 'thinking',
|
|
219
|
+
duration: 2.6,
|
|
220
|
+
morph: 0.4,
|
|
221
|
+
baseFace: false,
|
|
222
|
+
baseBody: false,
|
|
223
|
+
blinkIn: true,
|
|
224
|
+
pose: (t) => {
|
|
225
|
+
const mid = dotPulse(t, 1)
|
|
226
|
+
// Les points lateraux sortent des flancs de la boule : dans la video ils
|
|
227
|
+
// restent fusionnes avec elle 1-2 frames avant de se detacher.
|
|
228
|
+
const emerge = 0.3 + 0.7 * easings.easeOutCubic(clamp(t / 0.3))
|
|
229
|
+
return base({
|
|
230
|
+
// la boule DEVIENT le point du milieu : le morph reste continu
|
|
231
|
+
sil: circle(DOT_R * (1 + (DOT_PEAK - 1) * mid), { cx: DOT_X[1]! }),
|
|
232
|
+
eyeAlpha: 0,
|
|
233
|
+
dots: [0, 2].map((i) => {
|
|
234
|
+
const k = dotPulse(t, i)
|
|
235
|
+
return {
|
|
236
|
+
x: DOT_X[i]! * emerge,
|
|
237
|
+
y: 0,
|
|
238
|
+
r: DOT_R * (1 + (DOT_PEAK - 1) * k),
|
|
239
|
+
opacity: 0.55 + 0.45 * k
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
{
|
|
247
|
+
id: 'wink',
|
|
248
|
+
duration: 1.6,
|
|
249
|
+
morph: 0.3,
|
|
250
|
+
blinkIn: true,
|
|
251
|
+
baseFace: false,
|
|
252
|
+
baseBody: true,
|
|
253
|
+
pose: () =>
|
|
254
|
+
base({
|
|
255
|
+
gaze: { yaw: -5.37, pitch: 4.55, roll: 6.7 },
|
|
256
|
+
split: 16.25,
|
|
257
|
+
// L'oeil ferme n'est pas l'oeil ouvert ecrase : c'est un tiret
|
|
258
|
+
// horizontal PLUS LARGE que l'oeil ouvert (0.447 contre 0.236).
|
|
259
|
+
eyes: [
|
|
260
|
+
{ w: 0.236, h: 0.464, open: 1 },
|
|
261
|
+
{ w: 0.447, h: 0.089, open: 1 }
|
|
262
|
+
]
|
|
263
|
+
})
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
{
|
|
267
|
+
id: 'wide',
|
|
268
|
+
duration: 1.8,
|
|
269
|
+
morph: 0.55,
|
|
270
|
+
blinkIn: true,
|
|
271
|
+
baseFace: false,
|
|
272
|
+
baseBody: true,
|
|
273
|
+
pose: () =>
|
|
274
|
+
base({
|
|
275
|
+
gaze: { yaw: 6.92, pitch: -21.96, roll: 11.6 },
|
|
276
|
+
split: 18.43,
|
|
277
|
+
eyes: pair(0.356, 0.875)
|
|
278
|
+
})
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
{
|
|
282
|
+
id: 'alert',
|
|
283
|
+
duration: 2.4,
|
|
284
|
+
// le "!" revient en place a 1.6 + 0.4
|
|
285
|
+
minDuration: 2,
|
|
286
|
+
morph: 0.45,
|
|
287
|
+
baseFace: false,
|
|
288
|
+
baseBody: false,
|
|
289
|
+
blinkIn: false,
|
|
290
|
+
pose: (t) => {
|
|
291
|
+
// Course mesuree : -0.087 -> +0.732 en 1.5 s, ease-in-out, micro-overshoot.
|
|
292
|
+
const p = clamp(t / 1.5)
|
|
293
|
+
const travel = easings.easeInOutCubic(p) * 0.82 - 0.087
|
|
294
|
+
const back = t > 1.6 ? clamp((t - 1.6) / 0.4) : 0
|
|
295
|
+
const x = travel * (1 - back) + 0.1 * back
|
|
296
|
+
// Vibration secondaire a 2.5 Hz, barre et point en opposition de phase.
|
|
297
|
+
const buzz = Math.sin(t * 2.5 * TAU) * 0.005
|
|
298
|
+
const tilt = (17.7 * Math.PI) / 180
|
|
299
|
+
return base({
|
|
300
|
+
sil: barItalic({ rot: tilt, cx: x, cy: -0.325 - buzz }),
|
|
301
|
+
eyeAlpha: 0,
|
|
302
|
+
dots: [
|
|
303
|
+
{
|
|
304
|
+
// le point suit l'axe du glyphe, a 0.580 du centre de la barre
|
|
305
|
+
x: x - Math.sin(tilt) * 0.58,
|
|
306
|
+
y: -0.325 + Math.cos(tilt) * 0.58 + buzz * 2.8,
|
|
307
|
+
r: 0.118,
|
|
308
|
+
d: TEAR,
|
|
309
|
+
rot: (tilt * 180) / Math.PI,
|
|
310
|
+
opacity: 1
|
|
311
|
+
}
|
|
312
|
+
]
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
{
|
|
318
|
+
id: 'notify',
|
|
319
|
+
duration: 2.2,
|
|
320
|
+
morph: 0.5,
|
|
321
|
+
blinkIn: true,
|
|
322
|
+
baseFace: false,
|
|
323
|
+
baseBody: true,
|
|
324
|
+
pose: (t) => {
|
|
325
|
+
// Pop du point bleu : pic a +14 % vers 0.3 s puis stabilisation.
|
|
326
|
+
const p = clamp(t / 0.45)
|
|
327
|
+
const pop = 1 + (NOTIF_POP - 1) * Math.sin(p * Math.PI) * (1 - p * 0.35)
|
|
328
|
+
const r = NOTIF_R * (p < 1 ? pop : 1)
|
|
329
|
+
const a = (NOTIF_ANGLE * Math.PI) / 180
|
|
330
|
+
return base({
|
|
331
|
+
// le regard part a l'oppose de la pastille
|
|
332
|
+
gaze: { yaw: -21.94, pitch: -5.82, roll: -12.2 },
|
|
333
|
+
split: 18.89,
|
|
334
|
+
eyes: pair(0.505, 0.498),
|
|
335
|
+
notif: {
|
|
336
|
+
x: Math.cos(a) * NOTIF_DIST,
|
|
337
|
+
y: Math.sin(a) * NOTIF_DIST,
|
|
338
|
+
r,
|
|
339
|
+
notch: r + NOTIF_MARGIN
|
|
340
|
+
}
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
{
|
|
346
|
+
id: 'exclaim',
|
|
347
|
+
duration: 2,
|
|
348
|
+
morph: 0.45,
|
|
349
|
+
baseFace: false,
|
|
350
|
+
baseBody: false,
|
|
351
|
+
blinkIn: false,
|
|
352
|
+
pose: () =>
|
|
353
|
+
base({
|
|
354
|
+
sil: barUpright(),
|
|
355
|
+
eyeAlpha: 0,
|
|
356
|
+
dots: [{ x: -0.012, y: 0.526, r: 0.113, opacity: 1 }]
|
|
357
|
+
})
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
{
|
|
361
|
+
id: 'sleep',
|
|
362
|
+
duration: 2.4,
|
|
363
|
+
morph: 0.5,
|
|
364
|
+
baseFace: false,
|
|
365
|
+
baseBody: false,
|
|
366
|
+
blinkIn: false,
|
|
367
|
+
pose: (t) =>
|
|
368
|
+
base({
|
|
369
|
+
// Rebond vertical mesure : +-0.19 autour de +0.11, periode 0.6 s.
|
|
370
|
+
sil: circle(0.1585, { cy: 0.11 + Math.sin(t * (TAU / 0.6)) * 0.19 }),
|
|
371
|
+
eyeAlpha: 0
|
|
372
|
+
})
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
{
|
|
376
|
+
id: 'egg',
|
|
377
|
+
duration: 1.8,
|
|
378
|
+
morph: 0.4,
|
|
379
|
+
baseFace: false,
|
|
380
|
+
baseBody: false,
|
|
381
|
+
blinkIn: true,
|
|
382
|
+
pose: () =>
|
|
383
|
+
base({
|
|
384
|
+
sil: silhouette('egg'),
|
|
385
|
+
gaze: { yaw: 19.97, pitch: 26.01, roll: -17.1 },
|
|
386
|
+
// les yeux se resserrent comme le corps
|
|
387
|
+
split: 11.07,
|
|
388
|
+
eyes: pair(0.164, 0.385)
|
|
389
|
+
})
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
{
|
|
393
|
+
id: 'hexagon',
|
|
394
|
+
duration: 1.6,
|
|
395
|
+
morph: 0.4,
|
|
396
|
+
baseFace: false,
|
|
397
|
+
baseBody: false,
|
|
398
|
+
blinkIn: true,
|
|
399
|
+
pose: () =>
|
|
400
|
+
base({
|
|
401
|
+
sil: silhouette('hexagon'),
|
|
402
|
+
gaze: { yaw: 23.11, pitch: 24.42, roll: -13.3 },
|
|
403
|
+
split: 13.37,
|
|
404
|
+
eyes: pair(0.177, 0.411)
|
|
405
|
+
})
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
{
|
|
409
|
+
id: 'play',
|
|
410
|
+
duration: 2,
|
|
411
|
+
morph: 0.5,
|
|
412
|
+
baseFace: false,
|
|
413
|
+
baseBody: false,
|
|
414
|
+
blinkIn: true,
|
|
415
|
+
pose: (t) => {
|
|
416
|
+
// Le triangle reste quasi immobile pendant que le bouquet le traverse.
|
|
417
|
+
const fade = clamp(t / 0.35) * clamp((2.2 - t) / 0.5)
|
|
418
|
+
return base({
|
|
419
|
+
sil: spinningTriangle(0),
|
|
420
|
+
gaze: { yaw: 12, pitch: -8, roll: -6 },
|
|
421
|
+
split: 15,
|
|
422
|
+
eyes: pair(0.18, 0.34),
|
|
423
|
+
// le bouquet balaie de la droite vers la gauche par-dessus le triangle
|
|
424
|
+
arcs: SWOOSH.map((s, i) => ({
|
|
425
|
+
id: `sw${i}`,
|
|
426
|
+
seed: { ...s, cx: 0.45 - t * 0.42 },
|
|
427
|
+
t,
|
|
428
|
+
opacity: fade
|
|
429
|
+
}))
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
{
|
|
435
|
+
id: 'orbit',
|
|
436
|
+
duration: 3.4,
|
|
437
|
+
// le corps a fini de se relacher du triangle vers la boule a 1.6 + 0.9
|
|
438
|
+
minDuration: 2.5,
|
|
439
|
+
morph: 0.6,
|
|
440
|
+
baseFace: false,
|
|
441
|
+
baseBody: false,
|
|
442
|
+
blinkIn: false,
|
|
443
|
+
pose: (t) => {
|
|
444
|
+
// Rotation mesuree : rampe sur 0.35 s puis 1.25 tour/s (sens antihoraire).
|
|
445
|
+
const ramp = easings.easeInOutCubic(clamp(t / 0.35))
|
|
446
|
+
const rot = -TAU * 1.25 * t * ramp
|
|
447
|
+
// Le corps se relache du triangle vers la boule pendant l'orbite.
|
|
448
|
+
const back = easings.easeInOutCubic(clamp((t - 1.6) / 0.9))
|
|
449
|
+
const tri = spinningTriangle(rot)
|
|
450
|
+
const ball = circle(1, { rot })
|
|
451
|
+
const sil: Silhouette = {
|
|
452
|
+
radii: tri.radii.map((r, i) => r + (ball.radii[i]! - r) * back),
|
|
453
|
+
rot,
|
|
454
|
+
cx: tri.cx * (1 - back),
|
|
455
|
+
cy: tri.cy * (1 - back),
|
|
456
|
+
sx: 1,
|
|
457
|
+
sy: 1
|
|
458
|
+
}
|
|
459
|
+
const fade = clamp(t / 0.8) * clamp((3.6 - t) / 0.9)
|
|
460
|
+
return base({
|
|
461
|
+
sil,
|
|
462
|
+
// les yeux filent autour de la sphere ~3x plus vite que la silhouette
|
|
463
|
+
gaze: {
|
|
464
|
+
yaw: REST_GAZE.yaw + Math.sin(t * 6.5) * 65 * (1 - back),
|
|
465
|
+
pitch: -4 + back * 32,
|
|
466
|
+
roll: -13
|
|
467
|
+
},
|
|
468
|
+
eyes: pair(0.18, 0.34 + back * 0.07),
|
|
469
|
+
// les anneaux entrent un par un sur 0.8 s
|
|
470
|
+
arcs: RINGS.map((s, i) => ({
|
|
471
|
+
id: `rg${i}`,
|
|
472
|
+
seed: s,
|
|
473
|
+
t,
|
|
474
|
+
opacity: fade * clamp((t - i * 0.13) / 0.3)
|
|
475
|
+
}))
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
{
|
|
481
|
+
/**
|
|
482
|
+
* Entree dans la vue des reglages.
|
|
483
|
+
*
|
|
484
|
+
* SEUL etat qui n'est pas releve sur la video : il est CHOISI, comme la
|
|
485
|
+
* couleur `--ink`. Il emprunte le vocabulaire d'`orbit` — les memes anneaux,
|
|
486
|
+
* avec leurs parametres mesures — mais coupe court : 1 s au lieu de 3,4, la
|
|
487
|
+
* moitie des anneaux, et aucun triangle.
|
|
488
|
+
*
|
|
489
|
+
* Les deux drapeaux a `true` sont tout l'interet de cet etat :
|
|
490
|
+
*
|
|
491
|
+
* - `baseBody` laisse la forme choisie remplacer le corps, donc la vue peut
|
|
492
|
+
* imposer le cercle et le galet ou la goutte y MORPHENT au lieu de sauter ;
|
|
493
|
+
* - `baseFace` fait porter le visage de repos, donc le suivi du curseur
|
|
494
|
+
* s'applique des cette entree. Un etat qui aurait sa propre pose de regard
|
|
495
|
+
* (comme `orbit`) rendrait la main a l'etat suivant en pleine course, et
|
|
496
|
+
* les yeux sauteraient d'un coup a la reprise.
|
|
497
|
+
*
|
|
498
|
+
* Il n'est volontairement PAS dans `SEQUENCE` : ce n'est pas une animation du
|
|
499
|
+
* catalogue, c'est une transition d'interface.
|
|
500
|
+
*/
|
|
501
|
+
id: 'swirl',
|
|
502
|
+
// un peu plus que le tour du regard (`TURN_TIME`, 1,1 s) : les yeux doivent
|
|
503
|
+
// etre poses a gauche avant que les anneaux ne s'effacent
|
|
504
|
+
duration: 1.3,
|
|
505
|
+
minDuration: 1.3,
|
|
506
|
+
morph: 0.3,
|
|
507
|
+
baseFace: true,
|
|
508
|
+
baseBody: true,
|
|
509
|
+
// le morph de forme est masque par un clignement, comme partout ailleurs
|
|
510
|
+
blinkIn: true,
|
|
511
|
+
pose: (t) =>
|
|
512
|
+
base({
|
|
513
|
+
// trois anneaux sur les six d'`orbit` : la moitie du bouquet suffit a le
|
|
514
|
+
// reconnaitre, et c'est autant d'arcs en moins a rasteriser par image
|
|
515
|
+
arcs: RINGS.slice(0, 3).map((s, i) => ({
|
|
516
|
+
id: `sw${i}`,
|
|
517
|
+
seed: s,
|
|
518
|
+
t,
|
|
519
|
+
// ils entrent l'un apres l'autre puis s'effacent avant la fin du bloc,
|
|
520
|
+
// pour que la reprise au repos se fasse sur une image deja propre
|
|
521
|
+
opacity: clamp((t - i * 0.06) / 0.14) * clamp((1.22 - t) / 0.34)
|
|
522
|
+
}))
|
|
523
|
+
})
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
{
|
|
527
|
+
id: 'burst',
|
|
528
|
+
duration: 2.6,
|
|
529
|
+
// le corps est recompose a 1.7 + 0.7
|
|
530
|
+
minDuration: 2.4,
|
|
531
|
+
morph: 0.4,
|
|
532
|
+
baseFace: false,
|
|
533
|
+
baseBody: false,
|
|
534
|
+
blinkIn: false,
|
|
535
|
+
pose: (t) => {
|
|
536
|
+
// Effondrement mesure : 1.0 -> 0.166 en 0.7 s, ease-out, sans rebond.
|
|
537
|
+
const collapse = 1 - 0.834 * easings.easeOutQuint(clamp(t / 0.7))
|
|
538
|
+
const regrow = easings.easeOutQuint(clamp((t - 1.7) / 0.7))
|
|
539
|
+
return base({
|
|
540
|
+
sil: circle(collapse + (1 - collapse) * regrow),
|
|
541
|
+
eyeAlpha: clamp((t - 1.85) / 0.4),
|
|
542
|
+
dots: particles(t, 1),
|
|
543
|
+
dotsBehind: true
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
|
|
548
|
+
{
|
|
549
|
+
id: 'comet',
|
|
550
|
+
duration: 2.4,
|
|
551
|
+
// le point se recompose a 1.85 + 0.6 = 2.45, soit 0.05 s apres la coupe de
|
|
552
|
+
// la video : ce reliquat se termine pendant le fondu suivant, comme dans la
|
|
553
|
+
// reference. On ne descend donc pas sous la duree mesuree.
|
|
554
|
+
minDuration: 2.4,
|
|
555
|
+
morph: 0.45,
|
|
556
|
+
baseFace: false,
|
|
557
|
+
baseBody: false,
|
|
558
|
+
blinkIn: false,
|
|
559
|
+
pose: (t) => {
|
|
560
|
+
const collapse = 1 - (1 - COMET_DOT) * easings.easeOutQuint(clamp(t / 0.55))
|
|
561
|
+
const regrow = easings.easeOutQuint(clamp((t - 1.85) / 0.6))
|
|
562
|
+
const fade = clamp((t - 0.15) / 0.25) * clamp((1.95 - t) / 0.3)
|
|
563
|
+
return base({
|
|
564
|
+
// Le point derive de 0.035 vers le bas puis remonte (wobble mesure).
|
|
565
|
+
sil: circle(collapse + (1 - collapse) * regrow, {
|
|
566
|
+
cy: Math.sin(clamp(t / 1.7) * Math.PI) * 0.035
|
|
567
|
+
}),
|
|
568
|
+
eyeAlpha: clamp((t - 2) / 0.35),
|
|
569
|
+
arcs: COMET_RIBBONS.map((s, i) => ({ id: `cm${i}`, seed: s, t, opacity: fade }))
|
|
570
|
+
})
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
]
|
|
574
|
+
|
|
575
|
+
export const STATE_BY_ID = new Map(STATES.map((s) => [s.id, s]))
|
|
576
|
+
|
|
577
|
+
/** Ordre de lecture de la sequence complete, calque sur la video de reference. */
|
|
578
|
+
/**
|
|
579
|
+
* Date, en temps local, ou chaque etat est le plus lisible : c'est la pose que
|
|
580
|
+
* montrent les vignettes et la planche. Rendu deterministe, donc comparable
|
|
581
|
+
* d'une execution a l'autre. Le type force a couvrir tout nouvel etat.
|
|
582
|
+
*/
|
|
583
|
+
export const POSES: Record<StateId, number> = {
|
|
584
|
+
idle: 1,
|
|
585
|
+
thinking: 1.1,
|
|
586
|
+
wink: 0.8,
|
|
587
|
+
wide: 0.8,
|
|
588
|
+
alert: 0.75,
|
|
589
|
+
notify: 0.9,
|
|
590
|
+
exclaim: 0.8,
|
|
591
|
+
sleep: 0.45,
|
|
592
|
+
egg: 0.8,
|
|
593
|
+
hexagon: 0.8,
|
|
594
|
+
play: 0.9,
|
|
595
|
+
orbit: 1.2,
|
|
596
|
+
swirl: 0.5,
|
|
597
|
+
burst: 0.45,
|
|
598
|
+
comet: 1.15
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export const SEQUENCE: StateId[] = [
|
|
602
|
+
'idle',
|
|
603
|
+
'thinking',
|
|
604
|
+
'wink',
|
|
605
|
+
'wide',
|
|
606
|
+
'alert',
|
|
607
|
+
'notify',
|
|
608
|
+
'exclaim',
|
|
609
|
+
'sleep',
|
|
610
|
+
'egg',
|
|
611
|
+
'hexagon',
|
|
612
|
+
'play',
|
|
613
|
+
'orbit',
|
|
614
|
+
'burst',
|
|
615
|
+
'comet'
|
|
616
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { constrainPetDrag } from "./index";
|
|
4
|
+
|
|
5
|
+
describe("constrainPetDrag", () => {
|
|
6
|
+
it("keeps the pet inside the viewport margin", () => {
|
|
7
|
+
const rect = { left: 100, top: 100, right: 244, bottom: 244 };
|
|
8
|
+
const viewport = { width: 800, height: 600 };
|
|
9
|
+
|
|
10
|
+
expect(constrainPetDrag({ x: -500, y: 500 }, rect, viewport)).toEqual({
|
|
11
|
+
x: -92,
|
|
12
|
+
y: 348,
|
|
13
|
+
});
|
|
14
|
+
expect(constrainPetDrag({ x: 20, y: -30 }, rect, viewport)).toEqual({
|
|
15
|
+
x: 20,
|
|
16
|
+
y: -30,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
});
|