ai-chatbot/lib/bloub-official/expressions.ts

193 lines
5.4 KiB
TypeScript

import { EYE_H, EYE_SPLIT, EYE_W, REST_GAZE, type HeadGaze } from './face'
import { lerp } from './math'
import type { EyeCfg } from './states'
/**
* Expression de repos du bot.
*
* Le visage ne tient qu'à deux gélules, donc tout se joue sur quatre leviers :
* l'orientation de la tête, l'écart des yeux, leurs proportions, et
* l'inclinaison propre de chaque œil. C'est ce dernier qui permet la colère et
* la tristesse : elles demandent des inclinaisons EN MIROIR (les hauts qui
* convergent ou divergent), impossible avec le seul roulis de tête qui incline
* les deux yeux du même côté.
*
* Seul l'état de repos porte cette expression. Les états expressifs de la vidéo
* (clin d'œil, yeux écarquillés, notification) gardent la leur : c'est elle
* qu'on est venu reproduire.
*
* Les amplitudes s'appuient sur bible-strong-avatar-lab, qui expose le même
* modèle (tête X/Y/Z, largeur et hauteur par œil, écart, angle par œil) : chez
* eux la largeur va de 0,8 à 2,7 fois le neutre, la hauteur de 0,3 à 1,5, et
* les angles jusqu'à ±80°. On reste dans cette enveloppe.
*/
/** Enumeres pour que la couche i18n verifie leurs traductions a la compilation. */
export type ExpressionId =
| 'neutre'
| 'attentif'
| 'surpris'
| 'excite'
| 'heureux'
| 'hilare'
| 'colere'
| 'triste'
| 'effraye'
| 'mefiant'
| 'confus'
| 'curieux'
| 'fier'
| 'timide'
| 'blase'
| 'somnolent'
export interface BotExpression {
id: ExpressionId
gaze: HeadGaze
split: number
eyes: [EyeCfg, EyeCfg]
}
/** `tilt` en degrés, positif = le haut de la gélule part vers la droite. */
const eye = (w: number, h: number, tilt = 0, open = 1): EyeCfg => ({ w, h, tilt, open })
/** Les deux yeux identiques, inclinaisons en miroir si `tilt` est fourni. */
const pair = (w: number, h: number, tilt = 0, open = 1): [EyeCfg, EyeCfg] => [
eye(w, h, tilt, open),
eye(w, h, -tilt, open)
]
export const EXPRESSIONS: BotExpression[] = [
{
// la pose relevée image par image sur la vidéo de référence
id: 'neutre',
gaze: { ...REST_GAZE },
split: EYE_SPLIT,
eyes: [eye(EYE_W, EYE_H), eye(EYE_W, EYE_H)]
},
{
id: 'attentif',
gaze: { yaw: 4, pitch: 5, roll: -4 },
split: 16,
eyes: pair(0.21, 0.44)
},
{
id: 'surpris',
gaze: { yaw: 3, pitch: -3, roll: 0 },
split: 19,
eyes: pair(0.45, 0.47)
},
{
id: 'excite',
gaze: { yaw: 6, pitch: -14, roll: 0 },
split: 19.5,
eyes: pair(0.4, 0.56, -10)
},
{
// yeux plissés en arc : les hauts convergent légèrement
id: 'heureux',
gaze: { yaw: 5, pitch: 9, roll: 0 },
split: 17,
eyes: pair(0.27, 0.17, 14)
},
{
id: 'hilare',
gaze: { yaw: 4, pitch: 14, roll: 0 },
split: 18,
eyes: pair(0.34, 0.13, 20)
},
{
// hauts des yeux qui convergent fort vers le centre + yeux étrécis
id: 'colere',
gaze: { yaw: 3, pitch: 7, roll: 0 },
split: 17,
eyes: pair(0.34, 0.15, 30)
},
{
// l'inverse : les hauts divergent, et le regard tombe
id: 'triste',
gaze: { yaw: 3, pitch: -13, roll: 0 },
split: 16,
eyes: pair(0.22, 0.4, -28)
},
{
id: 'effraye',
gaze: { yaw: 2, pitch: -20, roll: 0 },
split: 20.5,
eyes: pair(0.4, 0.6)
},
{
// un œil franchement plus fermé que l'autre
id: 'mefiant',
gaze: { yaw: 12, pitch: 6, roll: -6 },
split: 16,
eyes: [eye(0.21, 0.4), eye(0.22, 0.15)]
},
{
// asymétrique sur les deux axes : tailles ET inclinaisons dépareillées.
// L'œil plissé est volontairement plat (rapport 1,6) : à un rapport proche
// de 1 il serait rond, et son inclinaison ne se verrait pas.
id: 'confus',
gaze: { yaw: -14, pitch: 3, roll: 8 },
split: 16.5,
eyes: [eye(0.2, 0.44, -18), eye(0.28, 0.17, 14)]
},
{
// la tête penche : c'est le roulis qui porte la curiosité
id: 'curieux',
gaze: { yaw: 16, pitch: -9, roll: -15 },
split: 16.5,
eyes: [eye(0.24, 0.46, -8), eye(0.2, 0.38, -8)]
},
{
id: 'fier',
gaze: { yaw: 5, pitch: 17, roll: 0 },
split: 17,
eyes: pair(0.3, 0.15, 18)
},
{
id: 'timide',
gaze: { yaw: -19, pitch: -14, roll: -7 },
split: 14,
eyes: pair(0.17, 0.3)
},
{
// fentes horizontales et regard qui part sur le côté
id: 'blase',
gaze: { yaw: -22, pitch: 2, roll: 0 },
split: 16,
eyes: pair(0.3, 0.12)
},
{
// paupières à moitié tombées : on passe par `open`, donc l'écrasement
// vertical à l'écran, le même mécanisme que le clignement
id: 'somnolent',
gaze: { yaw: 6, pitch: -9, roll: -3 },
split: 16,
eyes: pair(0.2, 0.42, 0, 0.42)
}
]
export const EXPRESSION_BY_ID = new Map<string, BotExpression>(EXPRESSIONS.map((e) => [e.id, e]))
export const DEFAULT_EXPRESSION = 'neutre'
const lerpEyeCfg = (a: EyeCfg, b: EyeCfg, t: number): EyeCfg => ({
w: lerp(a.w, b.w, t),
h: lerp(a.h, b.h, t),
tilt: lerp(a.tilt ?? 0, b.tilt ?? 0, t),
open: lerp(a.open, b.open, t)
})
/** Interpolation de deux expressions : le changement se fait en glissant. */
export function blendExpression(a: BotExpression, b: BotExpression, t: number): BotExpression {
return {
id: b.id,
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: [lerpEyeCfg(a.eyes[0], b.eyes[0], t), lerpEyeCfg(a.eyes[1], b.eyes[1], t)]
}
}