Заняття текстом як гра. Історія розробки та вибору технологій для застосунку про українську мову

Усі статті, обговорення, новини про Mobile — в одному місці. Підписуйтеся на телеграм-канал!

Усім привіт, мене звати Олег Новосад, я IT-архітектор, викладач, а тут я виступаю ще й як автор мобільного застосунку «Давай займемось текстом». У цьому тексті я розкрию технічні особливості створення застосунку «Давай займемось текстом».

Історія розробки цього продукту почалася з того, що я побачив допис Наталі Місюк на фейсбуці. Він містив дев’ять карток, і користувачам потрібно було відшукати єдину з них, де написане слово відповідало малюнку. Хоча сама ідея для такої забавки проста, вона давала відчуття свіжості: раніше мені не траплялося нічого подібно ані українською, ані іншими мовами. Люди захоплено коментували пост. Очевидно, їм дуже сподобалася запропонована гра.

Я також звернув увагу, що визначати правильну відповідь коментарями та лайки в соцмережах було не дуже зручно. Мені захотілося зробити процес комфортнішим — і я став думати, яку механіку можна було б тут застосувати, якби ця гра була мобільним застосунком. Далі мова піде саме про процес пошуку та вдалої реалізації обраної механіки.

Пошук рішення

Є такий термін — «toilet games». Ним зазвичай позначають маленькі простенькі ігри, у які можна бавитися в будь-яку вільну хвилину: поки сидиш у туалеті (власне, звідси й назва), їдеш у маршрутці або стоїш у черзі на касу в крамниці біля дому. Найчастіше — це просто вбивці часу, хоча з технічного боку нічого не заважає в тому самому форматі зробити щось корисне.

Зазвичай ці ігри робляться такими, у які можна грати з використанням найпростіших жестів: клік, свайп, подвійний тап тощо. Мені спало на думку, що тут добре підійде механіка, яка хоча і присутня в багатьох мобільних продуктах, та найбільшу популярність здобула завдяки відомому застосунку для знайомств «Тіндер». Там користувач змахує фотографії людей вправо або вліво залежно від того, викликають вони симпатію чи ні. Чому б не сортувати картки з українськими словами на правильні та неправильні в такий самий спосіб?

Я взяв 9 зображень з допису на фейсбук-сторінці Наталі і, оскільки працюю з технологією для розробки мобільних застосунків Flutter, вирішив дуже швидко написати демоверсію гри із запозиченою в Тіндера механікою. Ось що в мене вийшло:


На той момент зображення були зашиті в застосунок, хоча зараз — майже рік потому — усі малюнки в нас лежать на Firebase Storage.

Зробити оце саме змахування візуально гарним та зручним виявилося доволі складною задачею. За задумом картки із завданнями мусили лежати стосом, одна під одною, і коли користувач проводив про екрану, то верхня картка мала за плавною траєкторією вилетіти за край та зникнути, а відповідь — зарахуватися. Тоді зображення спочатку завантажувалися, а тільки потім починалася гра (зараз вони вже завантажуються в процесі непомітно для користувача). У демоверсії були три типи змахувань:

  • вліво — «неправильно»,
  • вправо — «правильно»,
  • вгору — «не знаю, лишити на потім».

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

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

Українська мова пережила багато складнощів. Якщо вже щось робити для її розвитку зараз, то робити якісно та естетично. Відповідно, я працював над тим, щоб зображення рухалися дугою, як це робили б справжні картки з картону.

Основний шмат widget’а, який показується користувачам в грі «Словотіндер» виглядає так:

...
return GestureDetector(
  child: LayoutBuilder(builder: (context, constraints) {
    final position = provider.position;
    final milliseconds = provider.isDragging ? 0 : 200;
    final center = constraints.smallest.center(Offset.zero);
    final angle = provider.angle * pi / 180;
    final rotatedMatrix = Matrix4.identity()
      ..translate(center.dx, center.dy)
      ..rotateZ(angle)
      ..translate(-center.dx, -center.dy);
    return AnimatedContainer(
      curve: Curves.easeInOut,
      duration: Duration(milliseconds: milliseconds),
      transform: rotatedMatrix..translate(position.dx, position.dy),
      child: GameCardWidget(cardImageUrl: widget.cardImageUrl!));
  }),
  onPanStart: (details) => provider.startPosition(details),
  onPanUpdate: (details) => provider.updatePosition(details),
  onPanEnd: (details) => provider.endPosition(),</code><code>
);
...

Ми відслідковуємо жести руху по картинці, які передаються далі в provider, що відповідно оновлює позицію та кут повороту, які впливають на відображення widget’а.

void updatePosition(DragUpdateDetails details) {
  _position += details.delta;
  _angle = 15 * _position.dx / _screenSize.width;
  notifyListeners();
}

Потрібно розуміти, що люди не обов’язково промальовують свайп пальцем до самого кінця екрану. Часто вони роблять це швидко і, скажімо, тільки на певний відсоток екрану. Мені потрібно було підібрати достатні значення x та y для того, щоб гральна картка не тільки повторювала рух за пальцем, але й самостійно завершувала переміщення за край екрана. Від цього також залежало, чи коректно будуть зараховуватися відповіді.

void startPosition(DragStartDetails details) {
  _isDragging = true;
  notifyListeners();
}


void updatePosition(DragUpdateDetails details) {
  _position += details.delta;
  _angle = 15 * _position.dx / _screenSize.width;
  notifyListeners();
}


void endPosition() {
  _isDragging = false;
  notifyListeners();


  switch (cardStatus()) {
    case CardStatus.correct:
      correct();
    break;
    case CardStatus.incorrect:
      incorrect();
    break;
    default:
      resetPosition();
    break;
  }
}


void resetPosition() {
  _isDragging = false;
  _position = Offset.zero;
  _angle = 0;
  notifyListeners();
}

Спочатку в нас було три стани: «сorrect», «incorrect» та «unknown», що відповідали трьом типам змахувань. Оскільки останнього ми потім позбулися, їх залишилося лише два.

CardStatus? cardStatus() {
  final x = _position.dx;
  const delta = 100;
  if (x >= delta) {
    return CardStatus.correct;
  } else if (x <= -delta) {
    return CardStatus.incorrect;
  }
  return null;
}

Від демоверсії до продукту

Демоверсію я відправив Наталі в травні 2022 року, вона їй дуже сподобалася. Наталя також зауважила, що запозичений з Тіндера механізм дуже добре лягає в концепцію проєкту, що із самого початку планувався як такий, що закохує в українську мову та руйнує стереотипи навколо неї.

Тож ми вирішили розвинути демоверсію та створити спільний гейміфікований мобільний застосунок, що буде водночас навчальним та розважальним. Те, що для розробки було вибрано Flutter, відкривало нам двері одразу в світ iOS та Android, а згодом, можливо, і Web чи навіть Desktop.

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

Додатково, для нас — це повна свобода для творчості та самореалізації, і це надзвичайно круте відчуття. Тож до роботи ми взялися із чималим ентузіазмом.

Щоправда, нам багато чого й бракувало. Наприклад, у нас не було дизайнера. Були 9 стартових зображень — і Наталя сказала, що буде значно більше. Їй можна вірити. На момент, коли я пишу статтю, у Словотіндері — саме так ми назвали гру, що стала першою в застосунку — уже 860 карток. Це означає, що кожен користувач має нагоду легко та із задоволенням збільшити свій словниковий запас на майже тисячу слів. Картки для ігор взялася робити Наталя за тим самим принципом, що робила їх для соцмереж.

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





Оскільки мовне питання стало одним з тих, що спричинило війну, і потреба в тому, щоб усі українці якомога швидше перейшли на українську була (і наразі залишається) дуже гострою, ми хотіли випустити застосунок якомога раніше.

Враховуючи стислі терміни та нашу мікрокоманду з двох осіб, я обрав стек технологій, що називаю по-своєму Firefly. Насправді, це комбінація Firebase та Flutter, що дозволяє нам швидко нарощувати функціонал та контент застосунку відносно малим коштом. Наш застосунок точно витримає навантаження до мільйона користувачів, можливо, навіть більше.

Серед того, що ми зараз використовуємо у Firebase:

  • Firebase Crashlytics — цей інструмент ми використовуємо для відслідковування проблем та помилок у застосунку. Зараз у нас показник crash-free становить 99,9%, що є дуже високим показником серед мобільних застосунків;
  • Firebase Performance — для відстежування ефективності роботи застосунку через показники запуску окремих екранів чи модулів;
  • Firebase Firestore — фактично база даних застосунку. Firestore має свої недоліки в управлінні, проте є класний платний інструмент Firefoo, який шалено спрощує життя і виводить для мене Firestore на новий рівень;
  • Firebase Messenger — для відправки пуш-повідомлень;
  • Firebase Authentication — для авторизації користувачів з акаунтами Facebook та Google, в перспективі ми можемо додавати смс-авторизацію тощо. Також це дозволяє нам анонімно авторизувати користувачів;
  • Firebase Storage — тут усі зображення й текстові матеріали для ігор та курсів. Також там лежить файл з усіх прикметників та іменників для генерації імен користувачів, що дозволяє нам динамічно збільшувати, удосконалювати їх та придумувати креативніші імена;
  • Firebase Functions — тут лежить наша умовна «серверна частина», написана на TypeScript. Функції відпрацьовують, як реакції на певні події в користувачів. Саме в такий спосіб працюють досягнення, сповіщення, оновлення про покупки, курси тощо.

Про архітектуру в проєкті

Ми вибрали дуже просту архітектуру на клієнті, вона називається рівневою (англійською layered architecture). Рівнів всього три: користувацький інтерфейс, що складається з екранів та віджетів, рівень бізнес-логіки й рівень репозиторію (взаємодія з базою даних та мережею). Також є ще умовний «четвертий» рівень, який проходить через інші — інфраструктурний рівень.

Більшість репозиторіїв, які працюють з даними, працюють напряму з Firestore, хоч і через абстракції на випадок зміни бази даних в майбутньому. Тут є свої плюси та мінуси. Плюс: нам вдається тримати динамічні темпи розробки застосунку, він дуже швидко працює. Мінуси — у нас можуть виникати непередбачувані ситуації, тому що багато оновлень інформації користувачів відбувається з мобільного застосунку.

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

Приклад маніпуляцій з вогірками, нашою ігровою валютою:

...
/// Adds [amount] of cucumbers to the user stats.
Future<void> addCucumbers(int amount) async {
  final user = await getCurrentUser();
  user?.stats.cucumbers += amount;
  await updateCurrentUser(user);
}


/// Subs [amount] of cucumbers from the user stats.
Future<void> subCucumbers(int amount) async {
  final user = await getCurrentUser();
  if ((user?.stats.cucumbers ?? 0) < amount) {
    throw NotEnoughCucumbersException();
  }
  user?.stats.cucumbers -= amount;
  await updateCurrentUser(user);
}
...

Ось власне оновлення користувача:

...
set user(User? value) {
  _user = value;
  notifyListeners();
}
...

Future<void> updateCurrentUser(User? user) async {
  logger.info("[$runtimeType] Updating current user");
  if (user == null) {
    logger.info('User passed is null');
    return;
  }

  if (user.id != this.user?.id) {
    logger.info('User(${user.id}) is not current user(${this.user?.id})');
    return;
  }


  await _usersRepository.update(user, merge: true);


  // Refresh current user if it was update to current user
  this.user = await getCurrentUser();
}
...

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

Усі технології, що використовуються, — прості та сучасні. Архітектура клієнта теж проста, архітектура на серверній стороні — так само (у нас на «бекенді» всього лиш 2000 рядків коду). Ця простота дає нам можливість швидко випускати нові версії застосунку, додавати рівні, оперативно реагувати на скарги та побажання від користувачів.

Завдяки тому, що ми вибрали модель serverless, тобто сервера як такого в нас немає, а застосунок взаємодіє безпосередньо із сервісами, і за основу взяли Firebase та сервіси GCP (Google Cloud Platform), у нас є можливість у майбутньому доповнювати застосунок новим функціоналом включно з інструментами ШІ, віддаленим конфігуруванням клієнтів, зі звітами для аналітики та реклами тощо.

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








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

Завантажуйте застосунок тут

👍ПодобаєтьсяСподобалось10
До обраногоВ обраному5
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

класна ідея та застосунок) мені ще сподобалась зручність онбордингу) це ви щось кастомне зробили чи інтегрували 3rd party?

Дякую дуже вам! Це fork онбордингу звідси: pub.dev/...​ckages/onboarding_overlay . Плануємо його трошки переписувати, але поки закриває наші базові потреби :)

Неймовірна стаття, дякую.
Не підкажете, як ви справляєтесь з адаптацією дизайну під різні екрани? (від дуже маленьких до дуже великих)

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

У Flutter є такий чудовий widget builder як LayoutBuilder, який дозволяє отримати поточні constraints (фактичні рамки дозволені батьківською компонентою), на базі яких вже можна зрозуміти обмеження і модифікувати візуальну частину в режимі реального часу. Деякі екрани в застосунку використовують його для адаптації шрифтів, розміщення елементів тощо. Його ж ми будемо й в подальшому використовувати для адаптації під планшети, і, маємо в планах, під web чи навіть desktop. Проте працюючи з більш широкими екранами треба не лише елементи на екрані розмістити іншим чином, а й об’єднати деякі екрани для зручності, а на це в нас поки що немає часу та ресурсів :)

Підписатись на коментарі