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 { 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 => ({ radii: [...BAR_UPRIGHT], rot: 0, cx: 0, cy: BAR_UPRIGHT_CY, sx: 1, sy: 1, ...pose }) const barItalic = (pose: Partial = {}): 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 = { 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' ]