Перший і успішний досвід роботи з Rive для анімації на клавіатурному тренажері

Нашому клавіатурному тренажеру Ratatype вже 13 років. Втім, довгий час у нас не була реалізована одна велика і важлива функція — повноцінне підказування рухів користувачеві.

Сама суть швидкодруку полягає в тому, що вам не потрібно дивитися на клавіатуру. Погляд має бути спрямований на екран. Для цього тренується мʼязова памʼять. В її основі — постійне повернення пальців на домашній рядок (ASDF клавіші та JKL;) та натискання конкретним пальцем лише на визначені клавіші. Тобто, середній палець правої руки, наприклад, відповідає за клавіші IK, а вказівний палець лівої — за RTFGVB.

Під час навчання користувач бачить перед собою віртуальну клавіатуру та руки на ній. Раніше це виглядало так:

Потрібні клавіші для натискання підсвічувалися, але самі пальці не рухалися.

Але ми вирішили, що хочемо нарешті анімувати рухи кистей і пальців, оскільки так тренажер працюватиме ефективніше.

Коли ми починали робити нову анімацію рук для typing tutor, задача звучала досить просто — анімувати руки, не витрачаючи на це багато ресурсів.

Перший підхід був максимально прямолінійним. Ми хотіли, щоб дизайнер Іра промалювала всі потрібні положення пальців у Figma, а я забрав би їх у форматі SVG і анімував на фронтенді. Для цього добре підходив GSAP. Був простий варіант, де ми просто змінювали opacity між різними SVG-станами кожного пальця. І був складніший варіант, де палець мав морфитися з одного положення в інше через SVG path animation.

Технічно це працювало, але дуже швидко стало зрозуміло, що такий підхід погано масштабується. Для кожної клавіші потрібно було б малювати окремий стан. Для кожного пальця — свій набір станів. Потім окремо думати про повернення в home row (домашній рядок — вихідне положення пальців під час навчання зі швидкого друку), про Shift, про різні клавіатурні розкладки, про символи, які вводяться через модифікатори.

Тобто навіть для примітивного способу потрібно було проробити купу роботи. Тому після обговорення вирішили перейти на спеціалізований інструмент для інтерактивної анімації. Іра обрала Rive. Для дизайнера це означало, що анімацію можна будувати як state machine: є стани, є переходи, є inputs, є triggers. Для розробника це означало інший тип інтеграції: React більше не малює анімацію сам, а лише відправляє команди в Rive.

Про інструмент. Нам потрібно було знайти рішення, яке дозволить не просто відтворювати готові анімації, а створити повністю інтерактивну систему, що реагуватиме на натискання клавіш у реальному часі. Плюс, важливо було працювати з векторною графікою, щоб фінальний файл був легким, а ілюстрації можна було масштабувати й редагувати.

Обрала Rive. Це одночасно і векторний редактор, і середовище для інтерактивної анімації. Головна перевага — анімаціями можна керувати програмно: потрібний рух запускається в потрібний момент, залежно від дій користувача. Замість купи окремих файлів виходить одна інтерактивна модель із плавними переходами між станами, при цьому вага файлу залишається невеликою.

Це сучасний інструмент, який уже використовують великі продукти, наприклад, Duolingo будує на Rive багато анімацій персонажів та елементів інтерфейсу.

Як робилася анімація на боці дизайну

Перш ніж анімувати, руки треба було намалювати й підготувати. У нас уже є свій персонаж — капітан Ratatype, і в нього є характерні білі рукавички. Щоб зберегти впізнаваний стиль, руки для тренажера теж зробили у вигляді білих рукавичок із контуром. Кожен палець я намалювала як окремий векторний шейп, що дало можливість підсвічувати кольором саме той палець, який натискає потрібну клавішу.

Далі був етап підготовки рук до подальшої анімації. Для природного руху обʼєктів у Rive є система кісток Bones, своєрідний внутрішній скелет ілюстрації. Для цього на контурі пальця розставляла прив'язні точки, які прикріплюються до конкретної кістки. Коли точка прив'язана, вона починає «слухатися» цієї кістки: рухається й повертається разом із нею.

Після такого зв'язування я вже не чіпаю сам контур пальця напряму. Уся анімація будується на тому, що я рухаю саме кістку, обертаю чи зміщую потрібний сегмент скелета, а м'який контур пальця автоматично деформується й тягнеться за нею. Це дає природний ефект згинання пальця до клавіші, а не просто пересування картинки.

Далі для кожної клавіші окремо створювала анімацію руху відповідного пальця — від домашнього рядка до потрібної клавіші.

Кожну зберігала в окремому таймлайні. У підсумку вийшло 112 таймлайнів, по два на кожну з 56 клавіш: перша анімація — плавний рух пальця до клавіші; друга — миттєвий перехід для швидкого набору тексту.

Усі таймлайни об'єднала у дві State Machine (така собі схема логіки всередині Rive, яка вирішує, яку анімацію показати в конкретний момент) — окремо для лівої та окремо для правої руки. Це дозволяє керувати руками незалежно одна від одної та відтворювати анімації паралельно. Наприклад, коли одна рука утримує Shift, а друга в цей час натискає літеру — обидві анімації працюють одночасно, без конфліктів.

Для керування цими анімаціями використали два типи інпутів у State Machine. Для швидкого друку застосували Trigger, оскільки він одноразово запускає анімацію та дозволяє миттєво перевести палець у потрібне положення. Для плавної анімації використали Boolean — він утримує стан активним, завдяки чому рух пальця повністю відтворюється від початку до кінця. Сама логіка звʼязків побудована навколо одного базового стану — домашнього рядка.

Що на фронтенді

Перший прототип був для одного пальця. На цьому етапі нічого не працювало з першої спроби, тому що ніхто з нас раніше не використовував Rive. Нам довелося розібратися, як працюють inputs, чим boolean відрізняється від trigger, як запускати state machine з React і як правильно отримувати input-и через rive-app. Після цього ми змогли запустити базовий сценарій: React каже showR, Rive переводить палець з home row до клавіші R.

Далі з’явилася головна проблема — швидкий друк.

У повільному сценарії все виглядало добре: рука плавно йшла до клавіші, користувач натискав її, рука поверталася назад у home row. Але коли користувач починав друкувати швидше, Rive поводився природно для state machine: нова команда перебивала поточну анімацію. Якщо палець ще не встиг дійти до R, а користувач уже натиснув T, попередня анімація обривалася. Візуально це виглядало як смикання пальця з idle-позиції в бік потрібної клавіші.

Я розглядав ідею черги: складати всі натискання в queue, програвати їх по одному, а коли черга росте — прискорювати анімацію. Але в Rive немає готового механізму, який би сам ставив transitions у чергу й адаптував їхню швидкість. Це можна було б писати на стороні React, але тоді виникала інша проблема: анімація починала відставати від користувача. Для typing tutor це погано, бо підказка має бути актуальною, а не догравати те, що користувач уже давно ввів.

Була ще одна ідея: під час швидкого друку не повертати палець у home row між клавішами. Тобто одразу переводити його з однієї клавіші до іншої. Але ми від цього відмовились. У навчанні друку home row — це не декоративна деталь, а основа навички. Особливо для початківців важливо бачити, що палець після натискання повертається в початкову позицію.

У результаті ми прийшли до компромісу: якщо користувач друкує швидко, ми не намагаємося дограти плавний рух до клавіші. Замість цього Rive має спеціальний trigger jumpX, який миттєво ставить палець у позицію натиснутої клавіші. Після цього ми запускаємо плавний reset назад до home row. Тобто, швидкий сценарій став таким: не встигли дійти до клавіші — миттєво «дострибуємо» до неї, а повернення назад все одно показуємо плавно.

Це виявилося ключовим рішенням. Ми не пропускаємо сам факт натискання, не залишаємо палець десь посередині, але й не намагаємося програти довгу анімацію, яка вже не встигає за користувачем.

З боку React логіка стала схожою на маленький контролер станів. Для кожної руки ми тримаємо режим: idle, showing, resetting.

  • Якщо рука в idle, ми дивимося на актуальну наступну клавішу та відправляємо в Rive команду show<KeyCode>.

  • Якщо користувач натиснув клавішу, поки рука ще в showing, ми fire-имо jump<KeyCode>, а потім запускаємо reset. Якщо рука вже в resetting, ми не перебиваємо reset. Просто запам’ятовуємо останню актуальну наступну клавішу та показуємо її після повернення руки в home row.

Складнощі на боці дизайну

Rive для мене був абсолютно новим інструментом, тож освоювала все з нуля: і принципи роботи, і логіку State Machine, і саму систему Bones. Значну частину часу забрала як раз підготовка до анімації, а не сама анімація.

З першого разу природний рух пальця не виходив: кістки або тягнули контур занадто різко, або навпаки — палець згинався неприродно, ламаючись у суглобі. Довелося кілька разів перероблювати прив'язку точок і підбирати вагу, поки рух не почав виглядати органічно.

Окрема проблема — анімація градієнта на пальцях, який використовувала для підсвічування пальця, що рухається. Градієнти в Rive поводяться інакше, ніж звичайна заливка: під час деформації контуру кісткою вони не завжди рухаються природно разом з формою, і це давало візуальні артефакти. Довелося окремо розбиратися, як налаштувати градієнт так, щоб він коректно тягнувся за пальцем під час руху, а не «плавав» окремо від ілюстрації.

Складнощі на боці розробки

Окремо довелося написати логіку для плавного повернення пальця в home row. Поточний reset у Rive — це transition до Idle з duration приблизно 200 ms. Подія StateChange у Rive для reset прилітає одразу, коли transition починається, а не коли візуальне повернення вже завершилося, тому орієнтуватися на нативну подію не можна. А писати для кожної клавіші додаткову анімацію повернення в home row було надмірним, тим паче, що це вже вміє робити reset. Тому тут ми відступили від контролю Rive та додали таймер в React, який чекає, поки візуально палець стане в home row, і потім запускає наступну анімацію.

Ми також змінили спосіб, у який React визначає, яку клавішу показувати. Спочатку було просто: символ R мапиться в showR, символ T — в showT. Але це одразу ламається на символах типу %. Користувач має ввести %, але фізично це та сама клавіша, що й 5, тільки з Shift. Тому правильна модель — мапити не символ, а фізичну клавішу.

У фінальній реалізації ми використовуємо коди JS KeyboardEvent.code: Digit5, KeyR, KeyT, ShiftLeft, ShiftRight. Для Rive це означає команди на кшталт showDigit5, jumpDigit5, showKeyR, jumpKeyR. А React отримує target не напряму з символу, а через активні клавіші поточної розкладки. Тому 5 і % ведуть до одного коду Digit5, але % додатково активує Shift.

Shift теж виявився непростим. У typing tutor Shift — це частина підказки. Якщо наступна літера велика, рука має показати Shift ще до того, як користувач фізично його натисне.

Тому ми розділили два поняття: shiftRequired і реальний event.shiftKey. Для анімації важливіше shiftRequired: якщо наступний символ потребує Shift, Rive показує відповідну руку на Shift.

Окремо довелося повозитися з діакритичними знаками.

На перший погляд, це ті самі літери, але для клавіатури вони вводяться не одним натисканням, а послідовністю: спочатку dead key, наприклад ^, ´ або `, а вже потім літера, з якої браузер збирає фінальний символ типу ê чи á.

Через це проста логіка «поточний символ -> активна клавіша -> Rive target» не працювала: підказка могла одразу показувати Shift або саму літеру, хоча користувач спочатку має натиснути діакритичну клавішу. Тому я додав окремий стан deadKeyPressed і взяв список всіх deadKeys для кожної розкладки. Якщо наступний символ є діакритичним і dead key ще не натиснуто, руки показують лише потрібну dead key клавішу. Після того як dead key уже введено, логіка перемикається на наступний крок і показує вже літеру або Shift, якщо літера велика.
Як працює тепер

Нині руки на нашому тренажері рухаються так само, як мають рухатися руки користувача під час друку:

Кожна потрібна клавіша підсвічується перед тим, як ви маєте на неї натиснути, пальці підіймаються та опускаються, розгинаються та згинаються:

Це був класний досвід із застосуванням зовсім нової для нас технології. Цікаво почути досвід колег, чи мали справу з цими інструментами, що порадите на майбутнє?

61
Community
Videos
About Us