Офлайн-компаньон для Genshin, HSR и ZZZ: как объединить три игры в одно приложение без бэкенда

Прокомментировать Просмотры: 3

Введение

Наверняка вам знакомо это навязчивое чувство перед сном: «А я вообще заходил сегодня за ежедневными бонусами, или те заветные 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> callback) {
  AppDatabase.getExecutor().execute(() -> {
    List entities = db.calendarDao().getCalendarForMonth(year, month);
    List 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 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);

});
}

Активация подписки или фиксация отметки мгновенно модифицируют целевую строку в базе данных.

При добавлении абонемента система валидирует лимиты, увеличивает счетчик на единицу и последовательно заполняет оставшиеся дни по убыванию, начиная с текущей даты (плюс 30 дней сегодня, плюс 29 завтра и так далее).

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

  • Полученные кристаллы: количество успешных отметок умножается на ежедневный номинал награды,

  • Полученные крутки: суммарный объем валюты пересчитывается в эквивалент молитв,

  • Упущенные кристаллы: пропущенные дни умножаются на дневную ставку,

  • Упущенные крутки: объем недополученной валюты переводится в молитвы,

  • Ожидаемые поступления: общий потенциал одной подписки умножается на их количество за вычетом уже собранных и потерянных бонусов,

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

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

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

Как это устроено на практике? Совершили прыжок в игре — открыли приложение, указали категорию баннера и внесли результат. Допускается добавление как одиночных позиций, так и целых десятков.

При массовом добавлении достаточно задать имя десятого по счету выпавшего предмета и его редкость. Любую запись можно в любой момент отредактировать. По умолчанию устанавливается 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.

Внесение записей осуществляется пакетно через методы Room, обновляя отображение в реальном времени. Метод addPulls записывает пачку новых строк, а 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, 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 молитвам), а приложение наглядно визуализирует прогресс с помощью шкалы заполнения. Награды с активных подписок могут зачисляться автоматически либо дополняться ручным вводом (скажем, после зачистки игрового мира). Доступно быстрое добавление по одной или сразу десять круток.

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

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

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

Напоминания

Функция ежедневных пуш-уведомлений — это то, с чего вообще зарождалась концепция всего приложения. Если утилита имеет соответствующие права и уведомления активированы в настройках, в заданный час (по умолчанию в 12:00) прилетит мягкое напоминание о необходимости забрать награды. Реализовано это посредством надежного инструмента WorkManager. Такой подход избавляет от необходимости разворачивать сложный бэкенд и полностью поддерживается гайдлайнами Google. Стоит учитывать, что начиная с Android 12 современные системы энергосбережения (режим Doze, агрессивные оптимизации оболочек 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 (начиная с тринадцатой версии) касательно разрешений на отправку уведомлений:

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

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

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

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

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

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

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

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

Итог

Я разрабатывал этот софт в первую очередь для собственного удобства, но со временем понял, что проект перерос рамки обычного хобби. Мне крайне важны отзывы и конструктивная критика сообщества:

  • В части кода и архитектуры: буду признателен за замечания по оптимизации Room-запросов и UI-логики (исходный код полностью открыт на GitHub).

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

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

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

 

Источник

Поделиться:

Похожие статьи

Поиск по играм, новостям и статьям…

Введите не менее двух символов

Введите не менее двух символов