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(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)] } }