4 технічні виклики «Врум-врум» — гри-тренажера з української мови

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

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

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

У нашої команди непогано виходить: уже пів року застосунок тримається в десятці найкращих застосунків України на App Store, має високі рейтинги, чимало завантажень та захоплених відгуків.

Цікава ідея народжує не менш цікаві рішення для її втілення. Я вже розповідав спільноті DOU про процес пошуку та вдалої реалізації обраної механіки. У цій статті я розповім про технічні виклики, що трапилися мені на шляху до розробки однієї з ігор — вона називається «Врум-врум» — у мобільному застосунку.

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

Як грати у «Врум-врум»

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

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

Ціль гри — успішно доїхати до фінішу.

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

Виклик 1: естетична привабливість

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

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

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

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

Але ми ризикнули — і вийшло на краще. Джун-дизайнер Христина Салабан чудово працює. Вона розташувала всі елементи для майбутньої «Врум-врум» на екрані певним чином, що в віджетах Flutter’а виглядає так:

Раніше, задовго до початку роботи над «Давай займемось тестом», я багато розробляв ігри за допомогою різних технологій: на Unity3d, на геймейкерах, навіть просто на нативному Android.

Коли я почав працювати з Flutter, то теж зацікавився, чи знайдуться тут можливості для створення ігор 2D та 3D. На той момент, ще в альфа-версії, був двіжок, що називався Flame. Я є одним із ентузіастів, що долучаються до розробки цього пакету, тож його я і застосував для застосунку про українську мову.

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

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

Flame Engine містить основний віджет, який відображає гру.

GameWidget<CarsGame>.controlled(gameFactory: () => CarsGame())

Власне, у грі я описав компоненти коду, що в ній розташовані. Наприклад, PlayerComponent — це сама автівка, яка їздить, пошкоджується та ремонтується. Пошкодження у цьому випадку — це аналогія із «життями», що традиційно присутні в іграх. Замість healthpoints, у нас просто є певна кількість ударів, які машинка витримує.

class PlayerComponent extends SpriteGroupComponent<int> with HasGameRef<CarsGame> {
  late Car car;


  late ExhaustComponent exhaust;
  late WheelsComponent wheels;


  @override
  void onLoad() async {
    final cars = await getIt<CarsGameManager>().getCars();
    final userCars = getIt<CurrentUserManager>().user?.inventory?.cars ?? [];
    final currentCar = getIt<SharedPreferences>().getString(kPrefsCurrentCar) ??
CarType.base.name;
    car = cars.firstWhere((car) => userCars.contains(car.type) && car.type.name == currentCar);


    scale = Vector2(.25, .25);
    anchor = Anchor.bottomCenter;


    sprites = {};
    for (final value in List<int>.generate(car.maxDamage + 1, (index) => index)) {
      sprites![value] = await Sprite.load('games/cars-game-assets/cars/${car.type.name}/car_crash_$value.png');
    }


    current = 0;


    exhaust = ExhaustComponent(carType: car.type);
    exhaust.position = Vector2(0, size.y - 4);
    exhaust.scale = Vector2(4, 4);
    position = Vector2(64, gameRef.size.y - 4);


    position = Vector2(64, gameRef.size.y - 4);


    if (car.type == CarType.futuristic) {
      wheels = WheelsComponent();
      wheels.position = Vector2(size.x / 2, size.y);


      await add(wheels);


      position = Vector2(64, gameRef.size.y - wheels.size.y / 3 - 4);
    }
}


  @override
  void update(double dt) {
    super.update(dt);


    if (current == car.maxDamage) {
      position = Vector2(56, gameRef.size.y + 4);
    }


    if (gameRef.isMoving) {
      if (!contains(exhaust)) {
        add(exhaust);
      }
    } else {
      removeWhere((component) => component == exhaust);
    }
  }


  void onIncorrect() {
    add(ExplosionComponent()..position = Vector2(size.x / 2, size.y / 2));


    add(RotateEffect.by(0.1, PerlinNoiseEffectController(duration: 1, frequency: 25)));


    current = current! + 1;
  }
}

Іншим компонентом у нас є дорога, на який машинка знаходиться.

class RoadComponent extends PositionComponent with HasGameRef<CarsGame> {
  @override
  void onLoad() {
    anchor = Anchor.bottomCenter;
  }


  @override
  void render(Canvas canvas) {
    canvas.drawRect(Rect.fromLTRB(0, 8, gameRef.size.x, 0),
    Paint()..color = const Color(0xFFC1CCCE));
  }
}

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

class SignComponent extends SpriteComponent with HasGameRef<CarsGame> {
  final signText = SignTextComponent();


  @override
  void onLoad() async {
    sprite = await Sprite.load('games/cars-game-assets/sign.png');
    size = Vector2(31, 34);
    anchor = Anchor.bottomCenter;


    signText.position = Vector2(size.x / 2 - 2, size.y / 2 - 2.5);
    position = Vector2(gameRef.size.x - 32, gameRef.size.y);


    add(signText);
  }
}

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

Крім того, є окремі компоненти, що називаються exhausts: це вихлопи з машинки. Анімація з ними додає реалістичності ефекту руху машинки.

class BackgroundComponent extends ParallaxComponent<CarsGame> {
  final randomizer = Random();


  @override
  void onLoad() async {
    final layersMeta = {
      'games/cars-game-assets/parallax/sun.png': [ImageRepeat.noRepeat, 0.1, Alignment(_doubleInRange(randomizer, -1, 1), 0)],
      'games/cars-game-assets/parallax/background.png': [ImageRepeat.repeatX, 1.0, Alignment.bottomLeft],
      'games/cars-game-assets/parallax/foreground.png': [ImageRepeat.repeatY, 1.5, Alignment.bottomLeft],
    };


    final layers = layersMeta.entries.map((entry) => gameRef.loadParallaxLayer(
      ParallaxImageData(entry.key),
      repeat: entry.value[0] as ImageRepeat,
      velocityMultiplier: Vector2((entry.value[1] as double), 1.0),
      alignment: entry.value[2] as Alignment,
    ));


    parallax = Parallax(await Future.wait(layers), baseVelocity: gameRef.isMoving ? Vector2(20, 0) : Vector2(0, 0));
}


  @override
  void update(double dt) {
    super.update(dt);


    parallax!.baseVelocity = gameRef.isMoving ? Vector2(20, 0) : Vector2(0, 0);
  }


  double _doubleInRange(Random source, num start, num end) => source.nextDouble() * (end - start) + start;
}

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

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

Але мене підтискав час: реліз потрібно було випустити в стислі терміни, також були (і є) питання розвитку та фінансового виживання нашого стартапу, що потребують уваги. Довелося задачу з рухом безпосередньо автівки визнати наразі не пріоритетною. Можливо, зможу повернутися до неї пізніше. Поки що рухається тільки фон.

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

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

Бонус: ми також здобули покращений UX усього застосунку та відкрили нові перспективи для розвитку.

Виклик 2: введення літер

У «Врум-врум» користувачі друкують слова на екранній клавіатурі. Звучить просто, але реалізувати це на практиці виявилося не дуже легко.

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

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

По факту при помилці ми дозволяємо вводити два символи, але завжди відображаємо лише один: останній введений.

...
// Replace old letter with the new one entered
if (value.length > 1) {
  _editingControllers[index].value = TextEditingValue(
    text: value[value.length - 1],
    selection: const TextSelection.collapsed(offset: 1)
  );
  return;
}
...

Також треба взяти до уваги стандартні моменти переходу. Скажімо, ви ввели логін, натисли кнопку «Прийняти» — і перестрибнули в наступну форму для введення пароля. Цю механіку ми використали для перестрибування літер у «Врум-врум». Користувач написав одну — перескочив у наступне поле. Написав літеру — перескочив знову. Якщо літера введена правильно, поле підсвічується зеленим, неправильно — червоним, водночас машинка пошкоджується.

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

...
if (value == widget.word[index]) {
  widget.onCorrect?.call();


  final allLettersCorrect = _editingControllers
    .asMap()
    .entries
    .where((e) => e.value.text != widget.word[e.key])
    .isEmpty;
  if (allLettersCorrect) {
    widget.onWordCompleted?.call();
    setState(() => _wordCompleted = true);
    return;
  }


  var nextLetterIndex = 0;
  while (isCorrect(nextLetterIndex)) {
    nextLetterIndex++;
  }


  setState(() {
    _lastEnteredChar = value;
    _currentLetterIndex = nextLetterIndex;
    _focusNodes[_currentLetterIndex].requestFocus();
  });
} else {
  widget.onIncorrect?.call();
  _animationControllers[index].reset();
  _animationControllers[index].forward(from: 0.0);
  setState(() => _lastEnteredChar = value);
}
...

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

Коли користувач натискає на кнопку «Підказка», 30% літер у слові випадковим чином заповнюються, тож гравцю потрібно лише доповнити слово рештою. Крім того, що мені потрібно було навчити систему рахувати ці 30% зрозумілим для користувачів чином (наприклад, скільки літер має відкритися для слів з чотирьох, семи або навіть сімнадцяти літер?), я завів для кожного поля окремий індекс.

Так, для завдання «ґудзик» є шість полів, відповідно, шість індексів: 0, 1, 2, 3, 4, 5. Якщо після застосування підказки виявилися заповненими полія 2 та 4, то користувач має самостійно написати «ґ», «у», далі система перестрибує через уже заповнену «д», користувач пише «з», далі перестрибуємо через «и» і гравець дописує «к». Завдання зараховується йому як правильне. Не можна опинитися в наступному полі, якщо не заповнив попереднє.

...
/// Fills empty boxes with letters when tip is used. By default it
/// will fill 30% of missing letters.
void _fillLettersOnTip({double percentage = 0.3}) {
  final random = Random();
  final lettersAmount = (widget.word.length * percentage).round();


  for (var index = 0; index < lettersAmount; index++) {
    final emptyControllers = _editingControllers
      .asMap()
      .entries
      .where((e) => e.value.text != widget.word[e.key])
      .toList();
    final randomController = emptyControllers[random.nextInt(emptyControllers.length)];
    final letter = widget.word[randomController.key];
    _editingControllers[randomController.key].value = TextEditingValue(
      text: letter, selection: const TextSelection.collapsed(offset: 1));
  }
}
...

Також у грі є кнопка «Відповідь». Якщо на неї натиснути, то все слово послідовно заповниться літерами, а завдання зарахується як правильне.

...
/// Fills in all letters for the word
void _fillLettersOnAnswer() {
  for (var index = 0; index < widget.word.length; index++) {
    _editingControllers[index].value = TextEditingValue(
      text: widget.word[index],
      selection: const TextSelection.collapsed(offset: 1));
  }
}
...

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

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

Виклик 3: подвійна загадка апострофа

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

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

Уже тільки після релізу, коли у «Врум-врум» масово почали грати користувачі, на нас посипалися скарги через апостроф, який зараховувся як помилка, хоча гравці вводили його правильно. Так ми дізналися, що Android та iOS мають різні апострофи.

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

Як бачите, різниця доволі ювелірна, тож не дивно, що користувачі на це не звертають увагу та навіть не здогадуються про процес. Ця фіча називається Smart Quotes — і вона за замовчуванням застосовується до будь-якого тексту — у тому числі й українською — що вводить користувач. На лихо, в нашій грі теж. SmartQuotes ставив не той апостроф, тобто не такий, як у нашій базі зі словами, відповідно система рахувала символ як неправильний. І несправедливо пошкодувала машинку.

Рішення просте: я відключив Smart Quotes за замовчуванням, встановивши в TextField атрибут smartQuotesType: SmartQuotesType.disabled.

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

Але скарги продовжували надходити, і мені довелося шукати причину знову. Вдалося виявити, що на iOS апострофи бувають як мінімум двох різновидів: схожий на кому (ʼ) і схожий на пряму риску ('). Різниця делікатна, тому не дуже помітна для ока. Вона знову проскочила повз тестування.

Рішення я застосував лаконічне: всі не наші апострофи замінюємо на такі, як у нашій базі. Вийшло, як кажуть на Львівщині, аліґансько. Працює. Більше проблем не виникало.

...
// Handle custom apostrophe case
if (value == "ʼ") {
  _editingControllers[index].value = const TextEditingValue(
    text: "'", selection: TextSelection.collapsed(offset: 1));
  return;
}
...

Апостроф на iOS здивував нас двічі. Уперше, коли з’ясувалося, що його можна переплутати з лапками. Вдруге — коли я відкрив для себе, що апострофи бувають дещо різної форми. Виправити обидва моменти було хвилинною справою. Набагато більше часу пішло на з’ясування причин.

Виклик 4: плин часу та гри

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

В усіх іграх під час проходження рівнів зберігався проміжний результат. Якщо користувач з якоїсь причини закривав гру на середині рівня, то потім він міг повернутися до неї з того місця, на якому зупинився. Зручно? Спочатку було так. А потім з’явилася «Врум-врум», що відрізнялася від інших обмеженням у часі: завдання в ній виконуються на швидкість. Це так добре для грабельності та навчальної користі.

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

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

Ще є така деталь: двіжок, на якому зроблена гра, дозволяє ставити її на паузу. Власне, це в нас і відбувається, коли з’являється будь-який діалог. Тож, якщо ви хочете подумати чи роздивитися машинку, дорогу, деревця, будиночки, то можете натиснути «Відповідь» або «Підказку» — і гра, а з нею разом і таймер, зупиняться.

Щоб користувачі мали додаткову мотивацію вчити слова та друкувати швидше, ми придумали систему заохочень. Якщо користувач впорається з будь-яким рівнем менше, ніж за 60 секунд, кожна заощаджена зараховується йому бонусами. Скажімо, гравець розв’язав усі завдання за 37 секунд. Це означає, що він здобуває 23 бонусних бали (60—37=23). Проста арифметична задача.

Але усе так просто тільки тоді, коли людина проходить рівень уперше. Що ж робити з подальшими спробами? Адже їх у нас може бути безліч. Що буде, якщо гравець під час другого проходження впорається за 31 секунду? Нарахувати додаткових 29 балів означало б відкрити двері в пекло для накручування рейтингу.

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

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

const query = await db.collection("users").get();


for (let doc of query.docs) {
  let data = doc.data();
  let expectedExperience = 0;
  let actualExperience = data['stats']['experience'];


  if (actualExperience == 0) {
    continue;
  }


  let games = data['stats']['progress']['games'];
  for (let game of games) {
    let levelsProgress = game['levelsProgress'];
    if (game['type'] != 'cars') {
      for (let levelProgress of levelsProgress) {
        expectedExperience += ~~(levelProgress['result'] / 10);
      }
    } else {
      for (let levelProgress of levelsProgress) {
        expectedExperience += ~~(levelProgress['result'] / 10);
        if (levelProgress['recordTime'] != null && levelProgress['recordTime'] < 60) {
          expectedExperience += 60 - levelProgress['recordTime'];
        }
      }
    }
  }


  if (actualExperience != expectedExperience) {
    await doc.ref.update({ "stats.experience": expectedExperience });
  }
}

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

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

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

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

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

Бонус: у команді з’явився дизайнер, а ми тепер маємо базу для розробки інших ігор на швидкість. Далі буде!

Ось тут ви можете дізнатися більше про навчальну користь «Врум-врум», а тут завантажити безкоштовно застосунок «Давай займемось текстом», що закохує в українську мову.

👍ПодобаєтьсяСподобалось9
До обраногоВ обраному4
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

Чудова стаття з детальним описом викликів та їх подолання. Дякую!

Дякую вам! Креативний застосунок вимагає креативних рішень :) На підході ще цікаві статті про подолання інших не менш складних технічних викликів 🙌

Чудова ідея додатку! Час від часу використовую додатки, щоб вивчати мови і найбільше не люблю набирати слова, бо багато займає енергії і легко втратити фокус. Як ідея можна спробувати flashcards чи вибір один з чотирьох

В нашому застосунку «Давай займемось текстом» зараз є декілька ігор, серед яких «Плутанка», що власне має формат «один з чотирьох» та «Словотіндер», механіку якої можна здогадатись з назви :)

Власне «Словотіндер» стала початком застосунку і про це детально описано тут , вам може бути цікаво :)

...

якщо помиляється — машинка пошкодується.

пошкоджується, ламається

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