import { arcRender, type ArcRender, type DotRender } from './decor' import { blendExpression, type BotExpression } from './expressions' import { decalageDesYeux } from './eyefit' import { blinkScale, eyePoses, liveliness } from './face' import { clamp, easings, lerp, r2 } from './math' import { blend, capsulePath, closedPath, radiusAtAngle, toPoints, type Point, type Silhouette } from './shape' import { STATE_BY_ID, type Pose, type StateDef, type StateId } from './states' export interface RenderedEye { d: string matrix: string alpha: number } export interface BotFrame { bodyPath: string bodyAlpha: number eyes: RenderedEye[] dots: DotRender[] /** true = les points passent derriere le corps (particules de l'eclatement) */ dotsBehind: boolean arcs: ArcRender[] notif: { x: number; y: number; r: number } | null notch: { x: number; y: number; r: number } | null } /** * Ou le bot porte son regard quand quelque chose d'exterieur le pilote — le * pointeur de la souris, aujourd'hui. * * `yaw` et `pitch` sont des directions ABSOLUES, qui remplacent celles de la pose * a mesure que `mix` monte. Deux raisons, chacune un piege deja tombe : * * - c'est le MOTEUR qui doit faire ce melange, pas l'appelant, parce que lui seul * connait la pose A CET INSTANT. Un appelant qui compenserait l'orientation de * l'expression lirait sa valeur d'arrivee pendant que le morph est encore en * cours, et les yeux sautaient a chaque changement d'humeur ; * - et il faut que ce soit absolu sur les DEUX axes. En relatif, la hauteur des * yeux suivait celle de chaque expression — « neutre » regarde a +28,6deg quand * les autres sont entre -9 et +9 — donc les yeux tombaient d'un coup au premier * changement d'humeur. Ce qui fait le caractere d'une expression pendant le * suivi, c'est la FORME de ses yeux (plisses, ronds, dissymetriques), pas * l'endroit ou elle regarde : celui-la, c'est le curseur qui le decide. * * `mix` dit a quel point l'exterieur commande la DIRECTION (0 = pas du tout). * * `wander` dit, separement, ce qui reste de derive automatique. Les deux ne se * confondent pas : quand le pointeur bouge, la derive doit s'eteindre — cumulees, * le bot aurait l'air de chercher le curseur sans jamais le tenir. Mais quand il * n'y a PAS de pointeur (arrivee au clavier, au tactile, ou souris sortie de la * fenetre), la tete doit rester tournee ET continuer de vivre. Les confondre * figeait le regard des que la vue s'ouvrait. * * `spin` est un tour a parcourir EN CHEMIN, en degres, qu'on fait fondre vers 0 * avec l'arrivee. Comme les yeux vivent sur une sphere, un tour les fait passer * derriere la boule et revenir de l'autre cote — et `-360deg` etant le meme * angle que `0`, il ne change rien a l'endroit ou ils se posent. */ export interface Look { yaw: number pitch: number mix: number spin: number wander: number } const NO_LOOK: Look = { yaw: 0, pitch: 0, mix: 0, spin: 0, wander: 1 } const lerpLook = (a: Look, b: Look, t: number): Look => ({ yaw: lerp(a.yaw, b.yaw, t), pitch: lerp(a.pitch, b.pitch, t), mix: lerp(a.mix, b.mix, t), spin: lerp(a.spin, b.spin, t), wander: lerp(a.wander, b.wander, t) }) const lerpEye = (a: Pose['eyes'][number], b: Pose['eyes'][number], t: number) => ({ w: lerp(a.w, b.w, t), h: lerp(a.h, b.h, t), open: lerp(a.open, b.open, t), tilt: lerp(a.tilt ?? 0, b.tilt ?? 0, t) }) /** Interpolation de deux poses. Le decor se croise en opacite, pas en geometrie. */ function blendPose(a: Pose, b: Pose, t: number): Pose { const out = 1 - t return { sil: blend(a.sil, b.sil, t), offX: lerp(a.offX, b.offX, t), offY: lerp(a.offY, b.offY, t), gaze: { yaw: lerp(a.gaze.yaw, b.gaze.yaw, t), pitch: lerp(a.gaze.pitch, b.gaze.pitch, t), roll: lerp(a.gaze.roll, b.gaze.roll, t) }, split: lerp(a.split, b.split, t), eyes: [lerpEye(a.eyes[0], b.eyes[0], t), lerpEye(a.eyes[1], b.eyes[1], t)], eyeAlpha: lerp(a.eyeAlpha, b.eyeAlpha, t), bodyAlpha: lerp(a.bodyAlpha, b.bodyAlpha, t), dots: [ ...a.dots.map((d) => ({ ...d, opacity: d.opacity * out })), ...b.dots.map((d) => ({ ...d, opacity: d.opacity * t })) ], arcs: [ ...a.arcs.map((r) => ({ ...r, id: `a${r.id}`, opacity: r.opacity * out })), ...b.arcs.map((r) => ({ ...r, id: `b${r.id}`, opacity: r.opacity * t })) ], // la pastille appartient a un seul des deux etats, elle ne se melange pas notif: t < 0.5 ? a.notif : b.notif, dotsBehind: t < 0.5 ? a.dotsBehind : b.dotsBehind } } /** * Moteur sans horloge : `sample(t)` est une fonction pure du temps. * * Consequence pratique : pause, reprise, ralenti et saut a une date arbitraire * donnent exactement la meme image, et le rendu est testable sans DOM. */ export class BotEngine { /** rayon de la boule au repos, en unites de viewBox */ readonly scale: number private cur: StateId private prev: StateId | null = null /** * Pose de depart FIGEE, posee seulement quand un changement d'etat arrive alors qu'un * fondu est deja en cours. Cf. `setState`. */ private departFige: Pose | null = null private tCur = 0 private tPrev = 0 private blinkAt = -10 private pts: Point[] = [] private shape: number[] | null = null private shapePrev: number[] | null = null private shapeAt = -10 private expr: BotExpression | null = null private exprPrev: BotExpression | null = null private exprAt = -10 private look: Look = NO_LOOK private lookPrev: Look = NO_LOOK private lookAt = -10 /** duree de rattrapage en cours ; voir `LOOK_MORPH`, sa valeur par defaut */ private lookMorph = 0.24 /** duree du morph quand on change la forme du corps */ static readonly SHAPE_MORPH = 0.45 /** * Duree de rattrapage du regard vers la cible. Plus court que `SHAPE_MORPH` : * un regard qui suit doit paraitre attentif, pas visqueux. Comme la cible est * reposee a chaque mouvement de souris, c'est cette duree qui donne au suivi * son inertie — le regard n'atteint jamais tout a fait un curseur qui bouge. */ static readonly LOOK_MORPH = 0.24 constructor( scale = 100, initial: StateId = 'idle', shape: number[] | null = null, expression: BotExpression | null = null ) { this.scale = scale this.cur = initial this.shape = shape this.expr = expression } /** * Expression de repos choisie dans le personnalisateur. Comme la forme, elle * glisse vers la nouvelle valeur au lieu de sauter. */ setExpression(expression: BotExpression | null, now = 0) { if (expression === this.expr) return this.exprPrev = this.expr this.expr = expression this.exprAt = now } /** Expression effective a l'instant `now`, morph en cours compris. */ private exprAtTime(now: number): BotExpression | null { const to = this.expr const from = this.exprPrev if (!to || !from) return to const k = (now - this.exprAt) / BotEngine.SHAPE_MORPH if (k >= 1) return to return blendExpression(from, to, easings.easeOutQuint(clamp(k))) } /** * Forme choisie dans le personnalisateur. Elle ne remplace le corps que sur * les etats au repos (`baseBody`) : sur les autres, la silhouette EST * l'animation et ne doit pas etre ecrasee. * * Le changement se fait en morph, pas d'un coup : comme toutes les formes sont * echantillonnees aux memes angles, il suffit d'interpoler les rayons. */ setShape(radii: number[] | null, now = 0) { if (radii === this.shape) return this.shapePrev = this.shape this.shape = radii this.shapeAt = now } /** * Forme effective a l'instant `now`, morph en cours compris. * * Ne remet PAS `shapePrev` a null en fin de morph : `sample` doit rester une * fonction pure du temps, donc relire une date passee doit redonner l'image * intermediaire. On garde juste une reference de plus. */ private shapeAtTime(now: number): number[] | null { const to = this.shape const from = this.shapePrev if (!to || !from) return to const k = (now - this.shapeAt) / BotEngine.SHAPE_MORPH if (k >= 1) return to const t = easings.easeOutQuint(clamp(k)) // alloue seulement pendant le morph ; hors morph on rend le tableau tel quel return to.map((r, i) => lerp(from[i] ?? r, r, t)) } /** * Nouvelle cible de regard, `null` pour revenir a celui de l'etat. * * Elle repart de la valeur COURANTE, et non de la cible precedente comme * `setShape` : cette methode est appelee a chaque mouvement de pointeur, et * repartir de l'ancienne cible ferait reculer le regard d'un cran avant * chaque rattrapage — le suivi tremblerait au lieu de glisser. * * Meme contrat que `setShape` par ailleurs : l'etat externe entre par un * setter horodate, jamais par une variable lue pendant `sample`, sinon le * moteur cesse d'etre une fonction pure du temps. */ setLook(look: Look | null, now: number, morph = BotEngine.LOOK_MORPH) { /* * Une cible non finie est refusee. Le moteur GARDE la derniere : un `NaN` * pose une seule fois se propagerait a chaque image et le bot ne se * reposerait plus jamais. C'est arrive pour de vrai — un * `getBoundingClientRect` sur une boite de taille nulle donne `0 / 0` chez * l'appelant. Celui-la est corrige, mais le moteur n'a pas a dependre de la * prudence de ses appelants pour rester rejouable. */ if (look && !Number.isFinite(look.yaw + look.pitch + look.mix + look.spin + look.wander)) { return } this.lookPrev = this.lookAtTime(now) this.look = look ?? NO_LOOK this.lookAt = now this.lookMorph = morph } /** Regard effectif a l'instant `now`, rattrapage en cours compris. */ private lookAtTime(now: number): Look { const k = (now - this.lookAt) / this.lookMorph if (k >= 1) return this.look return lerpLook(this.lookPrev, this.look, easings.easeOutQuint(clamp(k))) } private posed( def: StateDef, t: number, shape: number[] | null, expr: BotExpression | null ): Pose { let pose = def.pose(t) if (def.baseBody && shape) { // on garde la pose (rotation, decalage, squash) et on n'echange que le profil pose = { ...pose, sil: { ...pose.sil, radii: shape } } } if (def.baseFace && expr) { pose = { ...pose, gaze: expr.gaze, split: expr.split, eyes: expr.eyes } } return pose } /** * Decalage des yeux a l'instant `now` pour un etat donne, en unites de rayon de boule. * * Il est LU dans une table et interpole, jamais recalcule : `eyefit.ts` explique * pourquoi cette distinction est tout le correctif. Ici il ne reste qu'a l'interpoler * sur l'axe de la forme, avec exactement la courbe et la duree du morph de silhouette * — c'est la meme cause, donc ce doit etre le meme mouvement. * * On interroge la table sur les BORNES du morph (`shapePrev` et `shape`) et non sur le * profil que rend `shapeAtTime` : celui-la est un tableau neuf alloue a chaque image, * donc sans identite, et il n'existe dans aucune table. */ private decalageAtTime(now: number, state: StateId): { x: number; y: number } { /** * Un axe de morph : on lit la table sur ses deux BORNES et on interpole avec sa * courbe. Jamais sur la valeur interpolee — celle-la n'a pas d'identite et n'existe * dans aucune table, et c'est en la lui donnant a manger que les versions * precedentes tremblaient. */ const surAxe = ( debut: number, duree: number, a: { x: number; y: number }, b: { x: number; y: number } ) => { if (a === b) return b const k = (now - debut) / duree if (k >= 1) return b const t = easings.easeOutQuint(clamp(k)) return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t) } } // axe de l'expression, pour chacune des deux formes en presence const parForme = (radii: number[] | null) => surAxe( this.exprAt, BotEngine.SHAPE_MORPH, decalageDesYeux(radii, state, this.exprPrev?.id ?? null), decalageDesYeux(radii, state, this.expr?.id ?? null) ) // puis axe de la forme return surAxe( this.shapeAt, BotEngine.SHAPE_MORPH, parForme(this.shapePrev), parForme(this.shape) ) } get state(): StateId { return this.cur } /** * Repart sur `id` SANS etat precedent, comme un moteur neuf pose sur cet etat. * * C'est ce que veut dire « rembobiner » pour ce moteur. `setState` seul ne peut pas le * faire : il garde l'etat quitte pour le fondre, ce qui est exactement son role en * lecture, et exactement ce qu'il ne faut pas quand on revient au debut d'une sequence. * Rejouer l'image 0 apres une passe complete melangeait le premier etat avec le DERNIER, * et l'export GIF s'ouvrait sur une boule sans yeux — la comete a un `eyeAlpha` nul. * * `sample` reste une fonction pure du temps : comme `setState`, ceci est un setter DATE, * appele par le pilote de la sequence, jamais pendant un echantillonnage. */ reset(id: StateId, now: number) { this.cur = id this.prev = null this.departFige = null this.tCur = now this.tPrev = now this.blinkAt = -10 } /** * Origine du fondu en cours : la pose figee s'il y en a une, sinon l'etat quitte evalue * a son propre temps ecoule — donc encore en train de s'animer, ce qui est voulu. */ private origine( now: number, shape: number[] | null, expr: BotExpression | null ): Pose | null { if (this.departFige) return this.departFige if (!this.prev) return null const prevDef = STATE_BY_ID.get(this.prev)! return this.posed(prevDef, Math.max(0, now - this.tPrev), shape, expr) } /** * Pose composite a l'instant `now`, fondu en cours compris : exactement ce que `sample` * melange, avant la couche de vie au repos et de regard. Extraite pour que `setState` * puisse la figer. */ private poseComposee(now: number): Pose { const def = STATE_BY_ID.get(this.cur)! const shape = this.shapeAtTime(now) const expr = this.exprAtTime(now) const pose = this.posed(def, Math.max(0, now - this.tCur), shape, expr) const since = now - this.tCur if (since >= def.morph) return pose const origine = this.origine(now, shape, expr) if (!origine) return pose return blendPose(origine, pose, easings.easeOutQuint(clamp(since / def.morph))) } /** * Changement d'etat, date. * * Le moteur ne garde qu'UNE case d'historique, donc un changement qui arrive pendant un * fondu remplacait l'origine du melange par la pose PLEINE de l'etat qu'on quittait, au * lieu de l'image partiellement melangee qui etait a l'ecran. Mesure sur * `idle -> wide -> idle` a 100 ms : 35,9 px de saut contre 8,0 px de mouvement normal. * * On fige donc la pose composite courante et on melange depuis elle. Continu par * construction, quel que soit le nombre de changements enchaines. * * Et SEULEMENT dans ce cas. Figer a chaque changement arreterait net l'animation de * l'etat qu'on quitte pendant tout le fondu — le « ! » d'`alert` se figerait en pleine * course — alors qu'il n'y a rien a corriger hors morph : l'etat quitte y est deja * exactement l'image affichee. La lecture d'un montage, dont les blocs durent au moins * le plus long fondu (`MIN_BLOCK`), ne fige donc jamais rien et rend au bit ce qu'elle * rendait. */ setState(id: StateId, now: number) { if (id === this.cur) return const morph = STATE_BY_ID.get(this.cur)!.morph const enPleinFondu = this.prev !== null && now - this.tCur < morph this.departFige = enPleinFondu ? this.poseComposee(now) : null this.prev = this.cur this.tPrev = this.tCur this.cur = id this.tCur = now // Dans la video, chaque changement de forme est masque par un clignement. if (STATE_BY_ID.get(id)?.blinkIn) this.blinkAt = now } sample(now: number): BotFrame { const R = this.scale const def = STATE_BY_ID.get(this.cur)! const shape = this.shapeAtTime(now) const expr = this.exprAtTime(now) let pose = this.posed(def, Math.max(0, now - this.tCur), shape, expr) let decalage = this.decalageAtTime(now, this.cur) // --- transition ------------------------------------------------------- const since = now - this.tCur // L'etat precedent n'est jamais purge : `since < def.morph` suffit a // l'ignorer une fois le fondu passe, et l'oublier rendrait le moteur non // rejouable — relire une date d'avant la fin du fondu ne le retrouverait // plus. C'est l'optimisation qui parait innocente et qui casse tout. const origine = since < def.morph ? this.origine(now, shape, expr) : null if (origine) { // Ease-out exponentiel : c'est la courbe mesuree sur la video. Le corps // n'a pas d'overshoot (seuls la pastille et l'ouverture des yeux en ont). // Le ratio est borne : relire une date ANTERIEURE au changement d'etat // donnerait un ratio negatif, que l'ease-out extrapole — la silhouette // part alors trente fois trop loin. const ratio = easings.easeOutQuint(clamp(since / def.morph)) pose = blendPose(origine, pose, ratio) // Le decalage des yeux suit la MEME courbe que la silhouette qui le motive. Il vient // de l'etat quitte, que `setState` renseigne toujours en meme temps que l'origine — // le test est la pour le typage, pas pour un cas reel. const quitte = this.prev if (quitte) { const avant = this.decalageAtTime(now, quitte) decalage = { x: lerp(avant.x, decalage.x, ratio), y: lerp(avant.y, decalage.y, ratio) } } } // --- vie au repos ----------------------------------------------------- const alive = pose.eyeAlpha > 0.01 const look = this.lookAtTime(now) const life = liveliness(now, { wander: alive ? look.wander : 0, blink: alive }) const gaze = { // Les deux visees REMPLACENT celles de la pose au lieu de s'y ajouter (voir // `Look`), et le tour se retranche en chemin. La derive s'ajoute APRES le // melange, sinon la cible l'annulerait en meme temps que la pose — or elle // doit survivre a une tete tournee sans pointeur. yaw: lerp(pose.gaze.yaw, look.yaw, look.mix) + life.dYaw - look.spin, pitch: lerp(pose.gaze.pitch, look.pitch, look.mix) + life.dPitch, // le roulis, lui, ne suit rien : la tete du bot est penchee de -13deg dans // la video, et la faire rouler avec le curseur casse cette signature roll: pose.gaze.roll + life.dRoll } // clignement declenche par le changement d'etat, en plus du calendrier const forced = clamp((now - this.blinkAt) / 0.2) const forcedLid = forced < 1 ? Math.abs(forced * 2 - 1) : 1 const lid = Math.min(life.lid, forcedLid) const offX = pose.offX + life.driftX const offY = pose.offY + life.driftY // --- corps ------------------------------------------------------------ const sil: Silhouette = { ...pose.sil, cx: pose.sil.cx + offX, cy: pose.sil.cy + offY, sy: pose.sil.sy * life.breath } const bodyPath = closedPath(toPoints(sil, R, this.pts)) // --- yeux ------------------------------------------------------------- // Les yeux vivent sur une sphere de rayon 1 ; des que la silhouette n'est // plus un cercle, on les ramene au prorata du rayon reel dans leur // direction, sinon ils debordent et le masque les coupe. const bodyRadius = (x: number, y: number) => radiusAtAngle(pose.sil.radii, Math.atan2(y, x) - pose.sil.rot) const eyes: RenderedEye[] = [] if (pose.eyeAlpha > 0.01) { const poses = eyePoses(gaze, R, pose.split) for (let i = 0; i < 2; i++) { const e = poses[i]! if (e.depth <= 0.02) continue const cfg = pose.eyes[i]! const fit = bodyRadius(e.x, e.y) // Inclinaison propre de l'oeil : on compose le repere tangent avec une // rotation dans le plan de l'oeil (Basis x Rot). C'est ce qui permet des // inclinaisons en miroir entre les deux yeux. const phi = ((cfg.tilt ?? 0) * Math.PI) / 180 const cp = Math.cos(phi) const sp = Math.sin(phi) const ax = e.a * cp + e.c * sp const ay = e.b * cp + e.d * sp const cx2 = -e.a * sp + e.c * cp const cy2 = -e.b * sp + e.d * cp // Le clignement s'applique APRES tout ca : c'est un ecrasement vertical // a l'ecran, pas le long de l'axe de la gelule. const k = blinkScale(Math.min(lid, cfg.open)) eyes.push({ d: capsulePath(cfg.w * R, cfg.h * R), matrix: `matrix(${r2(ax)},${r2(ay * k)},${r2(cx2)},${r2(cy2 * k)},${r2(e.x * fit + (offX + decalage.x) * R)},${r2(e.y * fit + (offY + decalage.y) * R)})`, alpha: pose.eyeAlpha * clamp(e.depth / 0.12) }) } } // --- decor ------------------------------------------------------------ const dots = pose.dots .filter((p) => p.opacity > 0.01 && p.r > 0.0005) .map((p) => ({ ...p, x: (p.x + offX) * R, y: (p.y + offY) * R, r: p.r * R })) // la pastille est posee sur le contour : elle suit donc la forme aussi const nFit = pose.notif ? bodyRadius(pose.notif.x, pose.notif.y) : 1 const nx = pose.notif ? (pose.notif.x * nFit + offX) * R : 0 const ny = pose.notif ? (pose.notif.y * nFit + offY) * R : 0 const notif = pose.notif ? { x: nx, y: ny, r: pose.notif.r * R } : null const notch = pose.notif ? { x: nx, y: ny, r: pose.notif.notch * R } : null return { bodyPath, bodyAlpha: pose.bodyAlpha, eyes, dots, dotsBehind: pose.dotsBehind, // Les etats declarent des arcs en unites de rayon de boule ; le moteur // est le seul a connaitre l'echelle du viewBox, donc c'est lui qui trace. arcs: pose.arcs .filter((a) => a.opacity > 0.01) .map((a) => arcRender(a.seed, a.t, R, a.id, a.opacity)), notif, notch } } }