Введние

Знакомо ощущение, когда уже перед сном в голове проносится мысль: «А я зашел сегодня забрать ежедневные награды или 90 гемов только что сгорели?».

В поисках решения этой проблемы натыкаешься на целый кластер разных помощников. Функционал таких приложений‑компаньонов (так проще их назвать) заключался в отслеживании ресурсов и накоплений, учета круток в баннерах, помощи в сборке персонажей и так далее. Чаще всего они фокусируются на конкретной игре и нескольких инструментах для нее.

Идея

Я загорелся идеей создать проект, который объединит все популярные (а может, и не только) игры и соберёт воедино удобные инструменты в одном лёгком приложении. И всё это — с полным отсутствием рекламы, платных подписок и требований к интернет‑соединению.

Так родился мой pet‑проект GachaPoint — локальный компаньон для аниме‑RPG (пока только для Genshin Impact, Honkai: Star Rail и Zenless Zone Zero, но в дальнейшем планирую добавить поддержку и других игр).

Реализация

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

Календарь подписок

Календарь ­­­­­‑ то, с чего начался проект, решение собственной паранойи и просто удобный способ следить за ежедневными наградами. Смысл прост: забрал награды, зашел в приложение, открыл календарь нужной игры и нажал кнопку «Отметка».

Календарь отражает дни цветами: зеленый — награды собраны, красный — награды пропущены, голубой — предстоящие дни, белый — дни без наград (подписки не активны).

Вся информация хранится в локальной базе данных на SQLite (используя библиотеку Room). Под календарь выделена таблица calendar:

CREATE TABLE calendar (
    id INTEGER PRIMARY KEY,
    day INTEGER,
    day_of_year INTEGER,
    day_of_week INTEGER,
    month INTEGER,
    year INTEGER,
    status_genshin INTEGER,
    moon_days_remaining INTEGER,
    status_hsr INTEGER,
    express_pass_days_remaining INTEGER,
    status_zzz INTEGER,
    interknot_days_remaining INTEGER
);

Каждая строка — это день с указанием номера дня в году и неделе, номера месяца, номера года, статусом отметок и оставшихся активных дней подписки в играх. Все значения представлены в числовом виде. Для упрощения и ускорения взаимодействия, для календаря, используется «плоская» таблица без отношений. «Плоская» таблица упрощает и ускоряет один единственный SQL‑запрос для отрисовки всего месяца за один раз без JOIN‑операций и сложных связей, что критично для быстрых локальных выборок.

Через метод getMonthCalendarData из БД получаем массив‑месяц указанного года. Этот массив выводится на экран в виде календаря при помощи метода renderCalendarGrid и кастомного элемента интерфейса CalendarGrid на основе GridLayout.

public void getMonthCalendarData(int year, int month, GameType gameType, Callback<List<Date>> callback) {
  AppDatabase.getExecutor().execute(() -> {
    List<CalendarEntity> entities = db.calendarDao().getCalendarForMonth(year, month);
    List<Date> dates = new ArrayList<>(entities.size());
    for (CalendarEntity entity : entities) {
      dates.add(entity.toDateModel(gameType));
    }
    AppDatabase.postToMain(() -> callback.onResult(dates));
  });
}

Выбор обычного GridLayout с явным пересчетом LayoutParams в calendarGrid.post(...), а не стандартного RecyclerView с GridLayoutManager обусловлен несколькими причинами:

  • Количество ячеек строго ограничено (максимум 42 элемента для 6 недель). В переиспользовании View (ViewHolder) при таком малом объеме нет необходимости.

  • Чтобы сетка выглядела аккуратно на любых диагоналях и плотностях пикселей (dpi), ширина ячейки рассчитывается динамически исходя из реальной ширины calendarGrid, после чего высота приравнивается к ширине.

  • В зависимости от месяца (5 или 6 недель) нижний ряд либо скрывается, либо отображается. Через animateGridHeight мы плавно анимируем высоту самого контейнера, что с GridLayout делается буквально в пару строк и без багов с задержкой перерисовки списка.

private void renderCalendarGrid(List<CalendarCellUiModel> cells) 
{
  if (cells == null || cells.size() < 42) return;

  int activeRows = IntStream.range(35, 42).anyMatch(i -> cells.get(i).isVisible) ? 6 : 5;

  for (int i = 0; i < 42; i++) {
    TextView cellView = cellViewsPool.get(i);
    CalendarCellUiModel model = cells.get(i);

    if (i >= 35 && activeRows == 5) {
      cellView.setVisibility(View.GONE);
      cellView.setText("");
      cellView.setBackground(null);
      continue;
    }

    if (!model.isVisible) {
      cellView.setVisibility(View.INVISIBLE);
      cellView.setText("");
      cellView.setBackground(null);
    } else {
      cellView.setVisibility(View.VISIBLE);
      cellView.setText(String.valueOf(model.dayOfMonth));
      cellView.setBackgroundResource(model.backgroundRes);
      cellView.setTextColor(ContextCompat.getColor(requireContext(), model.textColorRes));
    }
  }

  calendarGrid.post(() -> {
    int gridWidth = calendarGrid.getWidth();
    if (gridWidth == 0 || !isAdded()) return;

    float density = getResources().getDisplayMetrics().density;
    int marginPx = (int) (4 * density);
    int cellSidePx = (gridWidth - (marginPx * 2 * 7)) / 7;

    for (int i = 0; i < 42; i++) {
      TextView cell = cellViewsPool.get(i);
      GridLayout.LayoutParams params = (GridLayout.LayoutParams) cell.getLayoutParams();
      if (params != null) {
        params.width = cellSidePx;
        params.height = cellSidePx;
        params.setMargins(marginPx, marginPx, marginPx, marginPx);
        cell.setLayoutParams(params);
      }
    }

    int targetHeight = (cellSidePx + (marginPx * 2)) * activeRows;

    animateGridHeight(targetHeight);
  });
}

Добавление подписки или отметки напрямую обновляет нужную строку в таблице.

Вызвав метод добавления подписки, мы проверяем лимит, прибавляем 1 в счетчик и в цикле добавляем значения оставшихся дней по убыванию с сегодняшнего дня (+ 30 сегодня, + 29 завтра, + 28 послезавтра и так далее).

При вызове метода отметки приложение проверяет возможность отметки (активна одна или более подписка в выбранной игре и отметки на дне еще нет) переписывает статус и обновляет ячейку календаря. Для ведения статистики мы рассчитываем количество пропусков и сборов на активные подписки, а после при помощи констант, рассчитываем данные по простым формулам:

  • Полученные гемы: собранные дни умножаем на ежедневную награду,

  • Полученные крутки: гемы умножаем на стоимость круток,

  • Пропущенные гемы: пропущенные дни на ежедневную награду,

  • Пропущенные крутки: пропущенные гемы на стоимость круток,

  • Гемы, которые еще получим: общий сбор с одной подписки умножим на количество подписок и вычтем собранные и пропущенные гемы,

  • Крутки, которые еще получим: гемы, которые еще получим, поделим на стоимость круток.

Счетчик круток

Игроки аниме‑RPG игр любят вести учет круток несмотря на то, что в играх есть официальная история. Причина этому — лимиты и отсутствие общей статистики. Записи в игре ограничены промежутком времени, после которого они перезаписываются. Такой инструмент тоже есть в моем приложении. С обновлениями я планирую добавить обширную статистику за всю историю круток и максимально простой экспорт истории из игры.

Как вообще работает? Крутишь баннер — открыл приложение, выбрал тип и добавляешь крутки. Можно сразу десятками, а можно по одной.

Десятками ты указываешь название последней (10 по счету за раз) крутки и ее редкость. Нажав на запись, ее можно отредактировать. По умолчанию для записи указывается редкость 3 звезды и название предмета «Неизвестно». Ведется счетчик гаранта, который можно сбросить, выбрав соответствующий чекбокс в редактировании или создании записи. Последующие записи начнут отсчет с нуля.

В базе данных есть таблица с учетом круток из всех игр:

CREATE TABLE pulls (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    game_type INTEGER,
    date_time TEXT,
    drop_rare TEXT,
    drop_type TEXT,
    banner_type TEXT DEFAULT 'event',
    is_reset_pity INTEGER DEFAULT 0
);

Таблица содержит в себе идентификатор игры (подписки), список круток с датой, редкостью, дропом, типом баннера и флагом на сброс гаранта. Записи из нужной таблицы фильтруются и выводятся в RecyclerView.

Добавление происходит сразу в таблицу БД и обновляет вид RecyclerView. Метод addPulls через Room записывает новые строки таблицы пачкой, а метод updatePull обновляет запись точечно при редактировании.

public void addPulls(String dateTime, String dropType, String dropRare, int count,
                     GameType gameType, String bannerType, boolean isResetPity, Runnable onComplete) {
  AppDatabase.getExecutor().execute(() -> {
    db.pullDao().addPullsBatch(dateTime, dropType, dropRare, count, gameType.getCode(), bannerType, isResetPity);
    if (onComplete != null) {
      AppDatabase.postToMain(onComplete);
    }
  });
}

public void updatePull(int id, String dateTime, String dropType, String dropRare,
                       GameType gameType, String bannerType, boolean isResetPity, Runnable onComplete) {
  AppDatabase.getExecutor().execute(() -> {
    db.pullDao().updatePullFields(id, dateTime, dropType, dropRare, bannerType, isResetPity);
    if (onComplete != null) {
      AppDatabase.postToMain(onComplete);
    }
  });
}

На основе выбранного типа игры (subType) происходит выборка значений, которые выводятся в массив Wishes, привязанный к отображению в RecyclerView.

Копилка гемов

Последний и самый простой инструмент — копилка. Принцип прост: мы указываем цель (+ 1 гарант, + 2 гаранта или + 6 гарантов к цели соответствует 77, 154 и 462 круткам к текущей цели) и приложение сохраняет прогресс в цифрах. Прогресс наглядно показан на прогресс‑баре. Мы можем собирать награды с луны, они сразу учтутся в прогрессе или добавить собранные гемы вручную (например, с зачистки карты). Добавить можно сразу 10 круток или 1 крутку за раз.

В Preferences записываются значения цели, сбора с подписки и ручного сбора. Запрос прогресса возвращает сложенные значения сбора с подписки и ручного сбора.

Ручное добавление просто прибавляет крутки к ручному сбору, а добавление из статистики перезаписывает значение сбора с подписки, дабы избежать постоянных лишних прибавлений при любом обновлении статистики. В методе расчета статистики просто выполняется команда перезаписи значения сбора с подписки в Preferences. А при ручном добавлении идет прибавление в ручной сбор.

Цель же выступает пределом добавления круток, если он достигнут, больше сбор не продолжится (добавленные крутки не прибавят счетчик) до сброса и установки новой цели.

Напоминания

Приложение каждый день будет напоминать о сборе наград в играх. Это тоже то, с чего в целом я и начинал задумку. Если выдано разрешение на уведомления и включены оповещения в настройках, то в указанное время (по умолчанию 12:00) придет оповещение о сборе наград. Реализовано это при помощи WorkManager. Это избавляет нас от лишних хлопот и бэкенда, а также одобряется Google. Начиная с Android 12, системы энергосбережения (Doze Mode, вендорские оптимизации Xiaomi/Samsung) агрессивно убивают фоновые задачи.

Так как регулярный интервал PeriodicWorkRequest составляет строго 24 часа, для точного срабатывания в выбранный пользователем час вычисляется стартовая задержка (initialDelay):

LocalDateTime now = LocalDateTime.now();
LocalDateTime targetTime = now.withHour(targetHour).withMinute(targetMinute).withSecond(0).withNano(0);

if (!targetTime.isAfter(now)) {
    targetTime = targetTime.plusDays(1);
}

long initialDelayMinutes = Duration.between(now, targetTime).toMinutes();

Для предотвращения дублирования задач при повторном вызове метода используется политика ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE.

Учтены жесткие требования современности (начиная с Android 13) по работе с уведомлениями и разрешениями:

  • Soft Check: Воркер проверяет пользовательский флаг ALLOW_NOTIFICATIONS из SharedPreferences. Если пользователь выключил тумблер в интерфейсе приложения, задание возвращает Result.success(), избегая ненужной работы.

  • Hard Check (Android 12+): проверяется системное разрешение POST_NOTIFICATIONS. Если пользователь отозвал его на уровне системы, воркер возвращает Result.failure(), сигналя о нестираемой ошибке доступа.

Ключевые фишки приложения

  • Календарь подписок: наглядно отслеживайте дни сбора ежедневных наград (Снабжение, Луна и др.), анализируйте полученные гемы и планируйте будущие поступления.

  • Счётчик и история круток: надёжно сохраняйте историю молитв/прыжков с быстрым доступом к личной статистике и гарантам.

  • Менеджер накоплений: рассчитывайте текущие ресурсы с автоматическим учётом доходов от активных подписок и ивентов.

  • Ежедневные напоминания: настраиваемые уведомления, которые не дадут забыть зайти в игру и забрать ежедневные награды.

  • Конфиденциальность и офлайн‑режим: вся ваша база данных и персональная статистика хранятся исключительно локально на устройстве.

Итог

Проект я создавал в первую очередь для себя, но решил, что неплохо развивать его дальше. Сейчас мне очень важен фидбек читателей Хабра:

  • По коду и архитектуре: буду рад критике и предложениям по улучшению Room‑запросов и UI‑логики (исходники открыты на GitHub (ссылка) ).

  • По UX/UI: если вы играете в гача‑игры и решите протестировать приложение — поделитесь в комментариях, насколько удобен текущий формат отслеживания и каких инструментов вам не хватает.

Спасибо за внимание! Буду рад ответить на любые вопросы в комментариях.

Полезные ссылки