Введние

Знакомо ощущение, когда уже перед сном в голове проносится мысль: «А я зашел сегодня забрать ежедневные награды или 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: если вы играете в гача‑игры и решите протестировать приложение — поделитесь в комментариях, насколько удобен текущий формат отслеживания и каких инструментов вам не хватает.

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

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

Комментарии (6)


  1. Yoti
    31.08.2026 15:16

    А чем не устроило официальное приложение? Да, оно не оффлайн, но и тот же функционал отметок требует доступа в Интернет...


    1. Waysoon Автор
      31.08.2026 15:16

      Смысл не только в оффлайне: Официальные сервисы/приложения часто изолированы по каждой игре отдельно или требуют переключения. Здесь же Genshin Impact, Honkai: Star Rail и ZZZ находятся в едином интерфейсе с одинаковой логикой (в планах еще больше игр). Внутриигровая история молитв стирается через несколько месяцев. Локальный счетчик позволяет хранить всю историю гарантов за все время без риска ее потери.


  1. Folko85
    31.08.2026 15:16

    Тут надо всё вручную вводить? Было бы неплохо из игры импортировать хотя бы крутки, как на paimon moe. Многие играют на телефонах, возможно есть вариант залезть в ресурсы и импортнуть, не проверял этот момент?


    1. Waysoon Автор
      31.08.2026 15:16

      Сейчас такой функции пока нет, но я ее планирую добавить.


  1. Ivan_shev
    31.08.2026 15:16

    Была идея сделать парсер который будет оповещать что разрабы дали промокоды, было бы прикольно в ваше приложение закинуть


    1. Waysoon Автор
      31.08.2026 15:16

      Хорошая идея, возьму на заметку.