557 lines
22 KiB
TypeScript

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
}
}
}