617 lines
17 KiB
TypeScript
617 lines
17 KiB
TypeScript
import {
|
|
COMET_DOT,
|
|
COMET_RIBBONS,
|
|
DOT_PEAK,
|
|
DOT_R,
|
|
DOT_X,
|
|
NOTIF_ANGLE,
|
|
NOTIF_DIST,
|
|
NOTIF_MARGIN,
|
|
NOTIF_POP,
|
|
NOTIF_R,
|
|
RINGS,
|
|
SWOOSH,
|
|
particles,
|
|
type ArcSpec,
|
|
type DotRender
|
|
} from './decor'
|
|
import { EYE_H, EYE_SPLIT, EYE_W, REST_GAZE, type HeadGaze } from './face'
|
|
import { TAU, clamp, easings } from './math'
|
|
import {
|
|
circle,
|
|
hullOfCircles,
|
|
polyPath,
|
|
profileFromPolygon,
|
|
silhouette,
|
|
type Silhouette
|
|
} from './shape'
|
|
|
|
export interface EyeCfg {
|
|
/** largeur locale (axe court de la gelule), en unites de rayon de boule */
|
|
w: number
|
|
/** hauteur locale (axe long) */
|
|
h: number
|
|
/** 1 = ouvert, 0 = ferme */
|
|
open: number
|
|
/**
|
|
* Inclinaison propre de la gelule, en degres, positif = le haut part a
|
|
* droite. Appliquee APRES le repere tangent de la sphere. Sans elle, les deux
|
|
* yeux penchent forcement du meme cote (le roulis de tete) et la colere comme
|
|
* la tristesse, qui demandent des inclinaisons en miroir, sont hors de portee.
|
|
*/
|
|
tilt?: number
|
|
}
|
|
|
|
export interface Pose {
|
|
/** silhouette du corps, en unites de rayon de boule */
|
|
sil: Silhouette
|
|
/** decalage global du corps ET des yeux */
|
|
offX: number
|
|
offY: number
|
|
gaze: HeadGaze
|
|
/** demi-ecart des yeux sur la sphere, en degres */
|
|
split: number
|
|
/** [oeil interieur, oeil exterieur] */
|
|
eyes: [EyeCfg, EyeCfg]
|
|
/** opacite des yeux : sert aux etats sans visage */
|
|
eyeAlpha: number
|
|
bodyAlpha: number
|
|
dots: DotRender[]
|
|
arcs: ArcSpec[]
|
|
notif: { x: number; y: number; r: number; notch: number } | null
|
|
/** true = le decor passe derriere le corps (particules de l'eclatement) */
|
|
dotsBehind: boolean
|
|
}
|
|
|
|
const pair = (w: number, h: number): [EyeCfg, EyeCfg] => [
|
|
{ w, h, open: 1 },
|
|
{ w, h, open: 1 }
|
|
]
|
|
|
|
function base(over: Partial<Pose> = {}): Pose {
|
|
return {
|
|
sil: circle(1),
|
|
offX: 0,
|
|
offY: 0,
|
|
gaze: { ...REST_GAZE },
|
|
split: EYE_SPLIT,
|
|
eyes: pair(EYE_W, EYE_H),
|
|
eyeAlpha: 1,
|
|
bodyAlpha: 1,
|
|
dots: [],
|
|
arcs: [],
|
|
notif: null,
|
|
dotsBehind: false,
|
|
...over
|
|
}
|
|
}
|
|
|
|
/* --------------------------------------------------- formes non radiales */
|
|
|
|
/**
|
|
* Barre du "!" vertical : enveloppe convexe de deux cercles.
|
|
* Mesure : cercle haut (0, -0.505) r 0.132, cercle bas (0, +0.130) r 0.075,
|
|
* flancs rectilignes. Elle est donc tronconique (rapport haut/bas 1.76).
|
|
*/
|
|
const BAR_UPRIGHT_CY = -0.1875
|
|
const BAR_UPRIGHT = profileFromPolygon(
|
|
hullOfCircles(0, -0.505, 0.132, 0, 0.13, 0.075),
|
|
0,
|
|
BAR_UPRIGHT_CY
|
|
)
|
|
|
|
/** Barre du "!" penche : capsule pure (largeur constante 0.269, longueur 0.776). */
|
|
const BAR_ITALIC = profileFromPolygon(hullOfCircles(0, -0.2535, 0.1345, 0, 0.2535, 0.1345), 0, 0)
|
|
|
|
const barUpright = (pose: Partial<Silhouette> = {}): Silhouette => ({
|
|
radii: [...BAR_UPRIGHT],
|
|
rot: 0,
|
|
cx: 0,
|
|
cy: BAR_UPRIGHT_CY,
|
|
sx: 1,
|
|
sy: 1,
|
|
...pose
|
|
})
|
|
|
|
const barItalic = (pose: Partial<Silhouette> = {}): Silhouette => ({
|
|
radii: [...BAR_ITALIC],
|
|
rot: 0,
|
|
cx: 0,
|
|
cy: 0,
|
|
sx: 1,
|
|
sy: 1,
|
|
...pose
|
|
})
|
|
|
|
/**
|
|
* Le point du "!" penche n'est pas un disque : c'est une goutte, bout rond
|
|
* (r 0.118) du cote de la barre et pointe effilee a l'oppose, longueur 0.300
|
|
* dans l'axe du glyphe. Centree sur le barycentre du bout rond.
|
|
*/
|
|
const TEAR = polyPath(hullOfCircles(0, 0, 0.118, 0, 0.172, 0.012))
|
|
|
|
/**
|
|
* Le triangle ne tourne pas sur lui-meme : son centre decrit un cercle de
|
|
* rayon 0.213 autour de l'origine (mesure). C'est ce decalage qui donne
|
|
* l'impression qu'il bascule au lieu de pivoter sur place.
|
|
*/
|
|
const TRI_ORBIT = 0.213
|
|
|
|
function spinningTriangle(rot: number): Silhouette {
|
|
return silhouette('triangle', {
|
|
rot,
|
|
cx: -TRI_ORBIT * Math.sin(rot),
|
|
cy: TRI_ORBIT * Math.cos(rot)
|
|
})
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ etats */
|
|
|
|
export type StateId =
|
|
| 'idle'
|
|
| 'thinking'
|
|
| 'wink'
|
|
| 'wide'
|
|
| 'alert'
|
|
| 'notify'
|
|
| 'exclaim'
|
|
| 'sleep'
|
|
| 'egg'
|
|
| 'hexagon'
|
|
| 'play'
|
|
| 'orbit'
|
|
| 'burst'
|
|
| 'comet'
|
|
/** transition d'interface, pas une animation du catalogue : hors `SEQUENCE` */
|
|
| 'swirl'
|
|
|
|
export interface StateDef {
|
|
id: StateId
|
|
/** duree de maintien quand la sequence complete est jouee */
|
|
duration: number
|
|
/**
|
|
* duree en dessous de laquelle l'animation est coupee avant d'aboutir : le
|
|
* "!" ne revient pas, le corps reste eclate. Elle se lit dans les constantes
|
|
* de `pose` ci-dessous, elle ne se choisit pas. Absente = l'etat ignore le
|
|
* temps ou boucle, n'importe quelle duree lui va (voir `MIN_BLOCK`).
|
|
*/
|
|
minDuration?: number
|
|
/** duree du morph d'entree */
|
|
morph: number
|
|
/** true = l'entree est masquee par un clignement, comme dans la video */
|
|
blinkIn: boolean
|
|
/**
|
|
* true = le corps est la silhouette "au repos", donc remplacable par la forme
|
|
* choisie dans le personnalisateur. Les etats qui dessinent leur propre forme
|
|
* (le "!", les points, l'oeuf, le triangle...) valent false : c'est cette forme
|
|
* la qui EST l'animation.
|
|
*/
|
|
baseBody: boolean
|
|
/**
|
|
* true = l'etat porte le visage "au repos", donc remplacable par l'expression
|
|
* choisie. Seul `idle` : les autres etats a visage ont une expression relevee
|
|
* sur la video, c'est precisement ce qu'on reproduit.
|
|
*/
|
|
baseFace: boolean
|
|
pose(local: number): Pose
|
|
}
|
|
|
|
/** Onde de pulsation qui parcourt les trois points de gauche a droite. */
|
|
function dotPulse(t: number, index: number): number {
|
|
const p = ((((t - index * 0.5) / 1.5) % 1) + 1) % 1
|
|
const k = p < 0.5 ? 0.5 - 0.5 * Math.cos(p * TAU) : 0
|
|
return clamp(k * 2)
|
|
}
|
|
|
|
export const STATES: StateDef[] = [
|
|
{
|
|
id: 'idle',
|
|
duration: 2.4,
|
|
morph: 0.45,
|
|
blinkIn: false,
|
|
baseFace: true,
|
|
baseBody: true,
|
|
pose: () => base()
|
|
},
|
|
|
|
{
|
|
id: 'thinking',
|
|
duration: 2.6,
|
|
morph: 0.4,
|
|
baseFace: false,
|
|
baseBody: false,
|
|
blinkIn: true,
|
|
pose: (t) => {
|
|
const mid = dotPulse(t, 1)
|
|
// Les points lateraux sortent des flancs de la boule : dans la video ils
|
|
// restent fusionnes avec elle 1-2 frames avant de se detacher.
|
|
const emerge = 0.3 + 0.7 * easings.easeOutCubic(clamp(t / 0.3))
|
|
return base({
|
|
// la boule DEVIENT le point du milieu : le morph reste continu
|
|
sil: circle(DOT_R * (1 + (DOT_PEAK - 1) * mid), { cx: DOT_X[1]! }),
|
|
eyeAlpha: 0,
|
|
dots: [0, 2].map((i) => {
|
|
const k = dotPulse(t, i)
|
|
return {
|
|
x: DOT_X[i]! * emerge,
|
|
y: 0,
|
|
r: DOT_R * (1 + (DOT_PEAK - 1) * k),
|
|
opacity: 0.55 + 0.45 * k
|
|
}
|
|
})
|
|
})
|
|
}
|
|
},
|
|
|
|
{
|
|
id: 'wink',
|
|
duration: 1.6,
|
|
morph: 0.3,
|
|
blinkIn: true,
|
|
baseFace: false,
|
|
baseBody: true,
|
|
pose: () =>
|
|
base({
|
|
gaze: { yaw: -5.37, pitch: 4.55, roll: 6.7 },
|
|
split: 16.25,
|
|
// L'oeil ferme n'est pas l'oeil ouvert ecrase : c'est un tiret
|
|
// horizontal PLUS LARGE que l'oeil ouvert (0.447 contre 0.236).
|
|
eyes: [
|
|
{ w: 0.236, h: 0.464, open: 1 },
|
|
{ w: 0.447, h: 0.089, open: 1 }
|
|
]
|
|
})
|
|
},
|
|
|
|
{
|
|
id: 'wide',
|
|
duration: 1.8,
|
|
morph: 0.55,
|
|
blinkIn: true,
|
|
baseFace: false,
|
|
baseBody: true,
|
|
pose: () =>
|
|
base({
|
|
gaze: { yaw: 6.92, pitch: -21.96, roll: 11.6 },
|
|
split: 18.43,
|
|
eyes: pair(0.356, 0.875)
|
|
})
|
|
},
|
|
|
|
{
|
|
id: 'alert',
|
|
duration: 2.4,
|
|
// le "!" revient en place a 1.6 + 0.4
|
|
minDuration: 2,
|
|
morph: 0.45,
|
|
baseFace: false,
|
|
baseBody: false,
|
|
blinkIn: false,
|
|
pose: (t) => {
|
|
// Course mesuree : -0.087 -> +0.732 en 1.5 s, ease-in-out, micro-overshoot.
|
|
const p = clamp(t / 1.5)
|
|
const travel = easings.easeInOutCubic(p) * 0.82 - 0.087
|
|
const back = t > 1.6 ? clamp((t - 1.6) / 0.4) : 0
|
|
const x = travel * (1 - back) + 0.1 * back
|
|
// Vibration secondaire a 2.5 Hz, barre et point en opposition de phase.
|
|
const buzz = Math.sin(t * 2.5 * TAU) * 0.005
|
|
const tilt = (17.7 * Math.PI) / 180
|
|
return base({
|
|
sil: barItalic({ rot: tilt, cx: x, cy: -0.325 - buzz }),
|
|
eyeAlpha: 0,
|
|
dots: [
|
|
{
|
|
// le point suit l'axe du glyphe, a 0.580 du centre de la barre
|
|
x: x - Math.sin(tilt) * 0.58,
|
|
y: -0.325 + Math.cos(tilt) * 0.58 + buzz * 2.8,
|
|
r: 0.118,
|
|
d: TEAR,
|
|
rot: (tilt * 180) / Math.PI,
|
|
opacity: 1
|
|
}
|
|
]
|
|
})
|
|
}
|
|
},
|
|
|
|
{
|
|
id: 'notify',
|
|
duration: 2.2,
|
|
morph: 0.5,
|
|
blinkIn: true,
|
|
baseFace: false,
|
|
baseBody: true,
|
|
pose: (t) => {
|
|
// Pop du point bleu : pic a +14 % vers 0.3 s puis stabilisation.
|
|
const p = clamp(t / 0.45)
|
|
const pop = 1 + (NOTIF_POP - 1) * Math.sin(p * Math.PI) * (1 - p * 0.35)
|
|
const r = NOTIF_R * (p < 1 ? pop : 1)
|
|
const a = (NOTIF_ANGLE * Math.PI) / 180
|
|
return base({
|
|
// le regard part a l'oppose de la pastille
|
|
gaze: { yaw: -21.94, pitch: -5.82, roll: -12.2 },
|
|
split: 18.89,
|
|
eyes: pair(0.505, 0.498),
|
|
notif: {
|
|
x: Math.cos(a) * NOTIF_DIST,
|
|
y: Math.sin(a) * NOTIF_DIST,
|
|
r,
|
|
notch: r + NOTIF_MARGIN
|
|
}
|
|
})
|
|
}
|
|
},
|
|
|
|
{
|
|
id: 'exclaim',
|
|
duration: 2,
|
|
morph: 0.45,
|
|
baseFace: false,
|
|
baseBody: false,
|
|
blinkIn: false,
|
|
pose: () =>
|
|
base({
|
|
sil: barUpright(),
|
|
eyeAlpha: 0,
|
|
dots: [{ x: -0.012, y: 0.526, r: 0.113, opacity: 1 }]
|
|
})
|
|
},
|
|
|
|
{
|
|
id: 'sleep',
|
|
duration: 2.4,
|
|
morph: 0.5,
|
|
baseFace: false,
|
|
baseBody: false,
|
|
blinkIn: false,
|
|
pose: (t) =>
|
|
base({
|
|
// Rebond vertical mesure : +-0.19 autour de +0.11, periode 0.6 s.
|
|
sil: circle(0.1585, { cy: 0.11 + Math.sin(t * (TAU / 0.6)) * 0.19 }),
|
|
eyeAlpha: 0
|
|
})
|
|
},
|
|
|
|
{
|
|
id: 'egg',
|
|
duration: 1.8,
|
|
morph: 0.4,
|
|
baseFace: false,
|
|
baseBody: false,
|
|
blinkIn: true,
|
|
pose: () =>
|
|
base({
|
|
sil: silhouette('egg'),
|
|
gaze: { yaw: 19.97, pitch: 26.01, roll: -17.1 },
|
|
// les yeux se resserrent comme le corps
|
|
split: 11.07,
|
|
eyes: pair(0.164, 0.385)
|
|
})
|
|
},
|
|
|
|
{
|
|
id: 'hexagon',
|
|
duration: 1.6,
|
|
morph: 0.4,
|
|
baseFace: false,
|
|
baseBody: false,
|
|
blinkIn: true,
|
|
pose: () =>
|
|
base({
|
|
sil: silhouette('hexagon'),
|
|
gaze: { yaw: 23.11, pitch: 24.42, roll: -13.3 },
|
|
split: 13.37,
|
|
eyes: pair(0.177, 0.411)
|
|
})
|
|
},
|
|
|
|
{
|
|
id: 'play',
|
|
duration: 2,
|
|
morph: 0.5,
|
|
baseFace: false,
|
|
baseBody: false,
|
|
blinkIn: true,
|
|
pose: (t) => {
|
|
// Le triangle reste quasi immobile pendant que le bouquet le traverse.
|
|
const fade = clamp(t / 0.35) * clamp((2.2 - t) / 0.5)
|
|
return base({
|
|
sil: spinningTriangle(0),
|
|
gaze: { yaw: 12, pitch: -8, roll: -6 },
|
|
split: 15,
|
|
eyes: pair(0.18, 0.34),
|
|
// le bouquet balaie de la droite vers la gauche par-dessus le triangle
|
|
arcs: SWOOSH.map((s, i) => ({
|
|
id: `sw${i}`,
|
|
seed: { ...s, cx: 0.45 - t * 0.42 },
|
|
t,
|
|
opacity: fade
|
|
}))
|
|
})
|
|
}
|
|
},
|
|
|
|
{
|
|
id: 'orbit',
|
|
duration: 3.4,
|
|
// le corps a fini de se relacher du triangle vers la boule a 1.6 + 0.9
|
|
minDuration: 2.5,
|
|
morph: 0.6,
|
|
baseFace: false,
|
|
baseBody: false,
|
|
blinkIn: false,
|
|
pose: (t) => {
|
|
// Rotation mesuree : rampe sur 0.35 s puis 1.25 tour/s (sens antihoraire).
|
|
const ramp = easings.easeInOutCubic(clamp(t / 0.35))
|
|
const rot = -TAU * 1.25 * t * ramp
|
|
// Le corps se relache du triangle vers la boule pendant l'orbite.
|
|
const back = easings.easeInOutCubic(clamp((t - 1.6) / 0.9))
|
|
const tri = spinningTriangle(rot)
|
|
const ball = circle(1, { rot })
|
|
const sil: Silhouette = {
|
|
radii: tri.radii.map((r, i) => r + (ball.radii[i]! - r) * back),
|
|
rot,
|
|
cx: tri.cx * (1 - back),
|
|
cy: tri.cy * (1 - back),
|
|
sx: 1,
|
|
sy: 1
|
|
}
|
|
const fade = clamp(t / 0.8) * clamp((3.6 - t) / 0.9)
|
|
return base({
|
|
sil,
|
|
// les yeux filent autour de la sphere ~3x plus vite que la silhouette
|
|
gaze: {
|
|
yaw: REST_GAZE.yaw + Math.sin(t * 6.5) * 65 * (1 - back),
|
|
pitch: -4 + back * 32,
|
|
roll: -13
|
|
},
|
|
eyes: pair(0.18, 0.34 + back * 0.07),
|
|
// les anneaux entrent un par un sur 0.8 s
|
|
arcs: RINGS.map((s, i) => ({
|
|
id: `rg${i}`,
|
|
seed: s,
|
|
t,
|
|
opacity: fade * clamp((t - i * 0.13) / 0.3)
|
|
}))
|
|
})
|
|
}
|
|
},
|
|
|
|
{
|
|
/**
|
|
* Entree dans la vue des reglages.
|
|
*
|
|
* SEUL etat qui n'est pas releve sur la video : il est CHOISI, comme la
|
|
* couleur `--ink`. Il emprunte le vocabulaire d'`orbit` — les memes anneaux,
|
|
* avec leurs parametres mesures — mais coupe court : 1 s au lieu de 3,4, la
|
|
* moitie des anneaux, et aucun triangle.
|
|
*
|
|
* Les deux drapeaux a `true` sont tout l'interet de cet etat :
|
|
*
|
|
* - `baseBody` laisse la forme choisie remplacer le corps, donc la vue peut
|
|
* imposer le cercle et le galet ou la goutte y MORPHENT au lieu de sauter ;
|
|
* - `baseFace` fait porter le visage de repos, donc le suivi du curseur
|
|
* s'applique des cette entree. Un etat qui aurait sa propre pose de regard
|
|
* (comme `orbit`) rendrait la main a l'etat suivant en pleine course, et
|
|
* les yeux sauteraient d'un coup a la reprise.
|
|
*
|
|
* Il n'est volontairement PAS dans `SEQUENCE` : ce n'est pas une animation du
|
|
* catalogue, c'est une transition d'interface.
|
|
*/
|
|
id: 'swirl',
|
|
// un peu plus que le tour du regard (`TURN_TIME`, 1,1 s) : les yeux doivent
|
|
// etre poses a gauche avant que les anneaux ne s'effacent
|
|
duration: 1.3,
|
|
minDuration: 1.3,
|
|
morph: 0.3,
|
|
baseFace: true,
|
|
baseBody: true,
|
|
// le morph de forme est masque par un clignement, comme partout ailleurs
|
|
blinkIn: true,
|
|
pose: (t) =>
|
|
base({
|
|
// trois anneaux sur les six d'`orbit` : la moitie du bouquet suffit a le
|
|
// reconnaitre, et c'est autant d'arcs en moins a rasteriser par image
|
|
arcs: RINGS.slice(0, 3).map((s, i) => ({
|
|
id: `sw${i}`,
|
|
seed: s,
|
|
t,
|
|
// ils entrent l'un apres l'autre puis s'effacent avant la fin du bloc,
|
|
// pour que la reprise au repos se fasse sur une image deja propre
|
|
opacity: clamp((t - i * 0.06) / 0.14) * clamp((1.22 - t) / 0.34)
|
|
}))
|
|
})
|
|
},
|
|
|
|
{
|
|
id: 'burst',
|
|
duration: 2.6,
|
|
// le corps est recompose a 1.7 + 0.7
|
|
minDuration: 2.4,
|
|
morph: 0.4,
|
|
baseFace: false,
|
|
baseBody: false,
|
|
blinkIn: false,
|
|
pose: (t) => {
|
|
// Effondrement mesure : 1.0 -> 0.166 en 0.7 s, ease-out, sans rebond.
|
|
const collapse = 1 - 0.834 * easings.easeOutQuint(clamp(t / 0.7))
|
|
const regrow = easings.easeOutQuint(clamp((t - 1.7) / 0.7))
|
|
return base({
|
|
sil: circle(collapse + (1 - collapse) * regrow),
|
|
eyeAlpha: clamp((t - 1.85) / 0.4),
|
|
dots: particles(t, 1),
|
|
dotsBehind: true
|
|
})
|
|
}
|
|
},
|
|
|
|
{
|
|
id: 'comet',
|
|
duration: 2.4,
|
|
// le point se recompose a 1.85 + 0.6 = 2.45, soit 0.05 s apres la coupe de
|
|
// la video : ce reliquat se termine pendant le fondu suivant, comme dans la
|
|
// reference. On ne descend donc pas sous la duree mesuree.
|
|
minDuration: 2.4,
|
|
morph: 0.45,
|
|
baseFace: false,
|
|
baseBody: false,
|
|
blinkIn: false,
|
|
pose: (t) => {
|
|
const collapse = 1 - (1 - COMET_DOT) * easings.easeOutQuint(clamp(t / 0.55))
|
|
const regrow = easings.easeOutQuint(clamp((t - 1.85) / 0.6))
|
|
const fade = clamp((t - 0.15) / 0.25) * clamp((1.95 - t) / 0.3)
|
|
return base({
|
|
// Le point derive de 0.035 vers le bas puis remonte (wobble mesure).
|
|
sil: circle(collapse + (1 - collapse) * regrow, {
|
|
cy: Math.sin(clamp(t / 1.7) * Math.PI) * 0.035
|
|
}),
|
|
eyeAlpha: clamp((t - 2) / 0.35),
|
|
arcs: COMET_RIBBONS.map((s, i) => ({ id: `cm${i}`, seed: s, t, opacity: fade }))
|
|
})
|
|
}
|
|
}
|
|
]
|
|
|
|
export const STATE_BY_ID = new Map(STATES.map((s) => [s.id, s]))
|
|
|
|
/** Ordre de lecture de la sequence complete, calque sur la video de reference. */
|
|
/**
|
|
* Date, en temps local, ou chaque etat est le plus lisible : c'est la pose que
|
|
* montrent les vignettes et la planche. Rendu deterministe, donc comparable
|
|
* d'une execution a l'autre. Le type force a couvrir tout nouvel etat.
|
|
*/
|
|
export const POSES: Record<StateId, number> = {
|
|
idle: 1,
|
|
thinking: 1.1,
|
|
wink: 0.8,
|
|
wide: 0.8,
|
|
alert: 0.75,
|
|
notify: 0.9,
|
|
exclaim: 0.8,
|
|
sleep: 0.45,
|
|
egg: 0.8,
|
|
hexagon: 0.8,
|
|
play: 0.9,
|
|
orbit: 1.2,
|
|
swirl: 0.5,
|
|
burst: 0.45,
|
|
comet: 1.15
|
|
}
|
|
|
|
export const SEQUENCE: StateId[] = [
|
|
'idle',
|
|
'thinking',
|
|
'wink',
|
|
'wide',
|
|
'alert',
|
|
'notify',
|
|
'exclaim',
|
|
'sleep',
|
|
'egg',
|
|
'hexagon',
|
|
'play',
|
|
'orbit',
|
|
'burst',
|
|
'comet'
|
|
]
|