Ежедневная рулетка наград в Construct 3 с Firebase

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

В этом уроке соберём не просто кнопку «получить монеты», а полноценную рулетку. Игрок сможет крутить её раз в сутки, к выигрышу сектора добавится бонус за серию входов, а результат сохранится в Firebase Realtime Database.

Сразу обозначу рамки: это хороший учебный прототип, но не готовая банковская система. Мы обойдёмся без регистрации и Firebase Authentication, поэтому не будем усложнять проект серверными функциями и токенами.

Что мы будем делать

Механика состоит из пяти частей:

  • получаем постоянный идентификатор игрока через LocalStorage;
  • запрашиваем текущее время у внешнего API;
  • загружаем или создаём запись игрока в Firebase;
  • определяем, доступна ли сегодняшняя награда;
  • после остановки рулетки начисляем приз и сохраняем обновлённый прогресс.

У игрока в базе будут только три значения:

{
  "coins": 0,
  "dailyStreak": 0,
  "lastRewardUnix": 0
}

coins — общий баланс, dailyStreak — текущая серия, lastRewardUnix — Unix-время последнего получения награды.

Что должно быть на сцене

На макете используются колесо roulette_wheel, кнопка btn_claim_reward, плашка reward_available, текст серии StrikeDayValue, иконки lockedIcon и служебный текст debug_text. Стрелка и подписи наград остаются обычными визуальными объектами: в экспортированных событиях вращается именно колесо.

Для каждого экземпляра lockedIcon добавляем instance variable:

dayIndex

Дальше присваиваем иконкам значения от 1 до 7. Благодаря этому одна функция сможет пройти по всем экземплярам и решить, какой день уже открыт, а какой ещё нужно показывать с замком.

Сцена ежедневной рулетки и переменная dayIndex у lockedIcon

На скриншоте выбран замок первого дня, поэтому у него dayIndex = 1. У второго ставим 2, у третьего 3 и так далее.

Плашка reward_available сделана спрайтом с двумя кадрами: награда недоступна и награда доступна. Это удобнее, чем каждый раз менять текст, фон и декоративные элементы по отдельности.

Кадр плашки с доступной ежедневной наградой

Кроме игровых объектов, в проект нужно добавить плагины LocalStorage, AJAX и JSON. Первый хранит ID на устройстве, второй отправляет HTTP-запросы, третий разбирает ответы API и Firebase.

Подготовка Firebase Realtime Database

Создаём проект в Firebase Console и подключаем Realtime Database.

Создание проекта в Firebase

После создания базы копируем её URL со вкладки Data. В проекте он записан в переменную FirebaseBaseUrl без завершающего слеша:

https://fir-dailyreward-default-rtdb.europe-west1.firebasedatabase.app

URL базы Firebase Realtime Database

REST API Firebase работает с окончанием .json, поэтому путь конкретного игрока будет собираться так:

FirebaseBaseUrl & "/players/" & PlayerID & ".json"

То есть данные лежат по адресу /players/PlayerID.json.

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

Правила на скриншоте требуют поля coins, dailyStreak и lastRewardUnix, запрещают посторонние поля и ограничивают серию диапазоном от 0 до 7.

Тут важно понять: .read: true и .write: true означают публичный доступ. Валидация защищает структуру, но не доказывает, что запрос отправил честный игрок. Для прототипа этого достаточно, а в реальном проекте стоит подключить Firebase Authentication и закрыть правила под авторизованного пользователя.

Переменные и структура событий

Основная логика собрана в группе Daily Reward System. В ней используются:

  • PlayerID — ID текущего игрока;
  • ServerUnix — время от сервера;
  • isServerTimeReady — успешно ли загружено время;
  • DailyStreak — серия ежедневных получений;
  • LastRewardUnix — время предыдущей награды;
  • isFirebaseReady — готовы ли данные игрока;
  • CanClaimReward — можно ли запускать рулетку;
  • CurrentDailyBonus — бонус текущего дня;
  • IsDailyStateChecked — защита от повторной проверки;
  • SecondsSinceReward — сколько секунд прошло с прошлой награды.

Переменные системы ежедневной награды

Флаги готовности здесь нужны из-за асинхронных запросов. Firebase и API времени могут ответить в разном порядке, а проверять награду можно только после получения обоих ответов.

Идентификация игрока через LocalStorage

Регистрации в этом примере нет. При старте макета проверяем ключ:

player_id

Если он существует, записываем LocalStorage.ItemValue в PlayerID и сразу запрашиваем игрока из Firebase. Если ключа нет, генерируем ID:

"player_" & floor(random(100000,999999))

Получается строка вроде player_483271. Сохраняем её под ключом player_id, и при следующем запуске на этом же устройстве загрузится тот же игрок.

Получение или создание PlayerID через LocalStorage

Это простой способ обойтись без формы входа. Но очистка данных браузера создаст нового игрока, а перенести прогресс на другое устройство не получится. Для учебной механики такое ограничение нормально.

Получаем серверное время

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

На старте отправляем AJAX-запрос:

https://time.now/developer/api/timezone/Etc/UTC

После ответа парсим AJAX.LastData через объект JSON, забираем поле unixtime и сохраняем его в ServerUnix:

int(JSON.Get("unixtime"))

Запрос серверного Unix-времени

Unix-время удобно тем, что это одно число в секундах. Нам не нужно сравнивать даты, месяцы и часовые пояса:

24 часа = 86400 секунд
48 часов = 172800 секунд

При успешном ответе ставим isServerTimeReady = 1, при ошибке оставляем 0. Для публикации игры также стоит проверить доступность API и CORS на целевой платформе, а на случай сбоя добавить повторный запрос или резервный источник времени.

Загружаем или создаём игрока в Firebase

Запрос с тегом load_player обращается к уже известному пути игрока. Если Firebase вернул данные, ответ не равен строке "null". Тогда разбираем JSON и загружаем:

Coins = int(JSON.Get("coins"))
DailyStreak = int(JSON.Get("dailyStreak"))
LastRewardUnix = int(JSON.Get("lastRewardUnix"))

После этого ставим isFirebaseReady = 1.

Загрузка существующего игрока или создание новой записи

Если ответ равен "null", записи ещё нет. Отправляем начальный JSON на тот же адрес:

{"coins":0,"dailyStreak":0,"lastRewardUnix":0}

Перед запросом задаём заголовок:

Content-Type: application/json

И используем метод PUT. Он подходит здесь лучше POST: точный путь /players/PlayerID.json уже известен, поэтому случайный ключ от Firebase нам не нужен.

Завершение создания игрока и обработка ошибок Firebase

После успешного create_player выставляем локальные значения в ноль и поднимаем флаг isFirebaseReady. Ошибки загрузки и создания выводятся в debug_text — это сильно упрощает проверку проекта.

Проверяем состояние ежедневной награды

Проверка запускается в Every tick, но только один раз:

isServerTimeReady = 1
& isFirebaseReady = 1
& IsDailyStateChecked = 0

Сразу ставим IsDailyStateChecked = 1, чтобы событие не повторялось каждый кадр, и считаем:

SecondsSinceReward = max(0, ServerUnix - LastRewardUnix)

max(0, ...) не даёт получить отрицательное значение, если внешний сервис вернул неожиданное время.

Бонус выбирается из строки:

10,20,50,75,100,125,150

Выражение из проекта:

int(tokenat("10,20,50,75,100,125,150",
    min(DailyStreak + 1, 7) - 1, ","))

DailyStreak + 1 выбирает следующий день, а min(..., 7) ограничивает серию седьмым бонусом.

Расчёт прошедшего времени и бонуса дня

Дальше идут четыре понятные ветки.

Первое получение

Если LastRewardUnix = 0, игрок ещё ничего не забирал. Ставим:

DailyStreak = 0
CanClaimReward = 1
CurrentDailyBonus = 10

Состояние первого получения награды

Нулевой DailyStreak означает именно отсутствие полученной награды. После первого успешного вращения он станет равен 1.

Прошло меньше 24 часов

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

LastRewardUnix > 0
& SecondsSinceReward < 86400

Награда недоступна раньше 24 часов

Здесь CanClaimReward = 0, а в отладку выводится оставшееся время в секундах.

Прошло от 24 до 48 часов

В промежутке от 86400 до 172800 секунд игрок сохранил серию и может получить следующий бонус:

LastRewardUnix > 0
& SecondsSinceReward >= 86400
& SecondsSinceReward < 172800

Награда доступна в промежутке от 24 до 48 часов

Ставим CanClaimReward = 1. Значение CurrentDailyBonus уже рассчитано по следующему дню серии.

Прошло 48 часов или больше

Если игрок пропустил окно:

SecondsSinceReward >= 172800

сбрасываем DailyStreak в 0, разрешаем награду и возвращаем бонус первого дня — 10.

Сброс серии после 48 часов

Максимальная серия равна семи. После седьмого дня значение остаётся 7, и игрок продолжает получать бонус седьмого дня — 150.

Обновляем рулетку

В группе Рулетка остаётся прежняя анимация вращения, но к клику добавляются два условия:

SpinDuration = 0
CanClaimReward = 1

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

Запуск и плавное вращение рулетки

Сектор выбирается случайным RewardIndex от 0 до 7, а EndAngle добавляет несколько полных оборотов и доворачивает колесо к нужному сектору. Небольшой random(-10,10) не даёт стрелке каждый раз останавливаться строго по центру.

Когда SpinTime >= SpinDuration, получаем награду сектора:

int(tokenat("100,10,15,20,25,30,40,50",
    RewardIndex, ","))

После этого складываем два приза:

TotalReward = RouletteReward + CurrentDailyBonus
Coins = Coins + TotalReward

Например, если колесо дало 25, а серия принесла 75, игрок получит 100 монет.

Затем фиксируем момент получения и увеличиваем серию:

LastRewardUnix = ServerUnix
DailyStreak = min(DailyStreak + 1, 7)

Начисление награды и обновление серии

В конце вызываем UpdateDailyUI и SavePlayerToFirebase. Отдельное событие для клика при CanClaimReward = 0 ничего не запускает, а только пишет в отладку, что награда пока недоступна.

Недоступный повторный запуск и результат сохранения

Оптимизация через функции

Без функций одинаковые действия пришлось бы копировать в первое получение, обычный день, сброс серии и завершение рулетки. Работать это будет, но поддерживать такую таблицу событий быстро станет неприятно.

UpdateDailyUI

Функция выполняет три действия:

StrikeDayValue.Text = DailyStreak & " days"
reward_available.AnimationFrame = CanClaimReward
lockedIcon.AnimationFrame =
    clamp(1 + DailyStreak - lockedIcon.dayIndex, 0, 1)

Функция UpdateDailyUI

По сути, функция синхронизирует весь интерфейс с текущими переменными. Формула для lockedIcon сравнивает серию с dayIndex каждого экземпляра и переводит результат в кадр 0 или 1.

Если захотите показывать сумму ближайшего бонуса в отдельном тексте, логично добавить обновление этого текста сюда же. В текущем экспорте функция меняет только StrikeDayValue, reward_available и lockedIcon.

SavePlayerToFirebase

Вторая функция собирает актуальные значения в JSON и отправляет PUT:

{
  "coins": Coins,
  "dailyStreak": DailyStreak,
  "lastRewardUnix": LastRewardUnix
}

Функция SavePlayerToFirebase

Точный запрос из событий уходит на:

FirebaseBaseUrl & "/players/" & PlayerID & ".json"

Так событие завершения рулетки отвечает только за игровую логику, а детали HTTP-запроса лежат в одном месте. Если структура игрока изменится, править сохранение придётся только в функции.

Проверка результата

Для первого теста удобно оставить debug_text на экране и пройти такой сценарий:

  1. Очистить player_id в LocalStorage и запустить макет.
  2. Убедиться, что создан player_XXXXXX и запись появилась в /players.
  3. Проверить, что первая награда доступна и даёт бонус 10.
  4. Прокрутить рулетку и сверить coins, dailyStreak = 1 и lastRewardUnix в Firebase.
  5. Перезапустить макет: рулетка должна быть заблокирована, пока не пройдёт 86400 секунд.
  6. Для ускоренного теста вручную изменить lastRewardUnix в Firebase на время старше 24 и 48 часов и проверить обе ветки.
  7. Дойти до DailyStreak = 7 и убедиться, что серия больше не растёт, а бонус остаётся 150.

Отдельно проверьте ошибки сети. Если не пришло серверное время или данные Firebase, флаги готовности останутся нулевыми и награда не должна выдаваться.

Что можно улучшить дальше

Первым делом я бы добавил обратный отсчёт до следующей попытки. Нужное значение уже есть:

86400 - SecondsSinceReward

Дальше можно подключить Firebase Authentication, перенести критичные расчёты на доверенный сервер, добавить повтор запросов при сетевых ошибках и сохранить ожидающее обновление локально. Ещё один полезный шаг — не только менять кадр плашки, но и визуально отключать кнопку.

Но даже в текущем виде мы получили законченную связку: постоянный игрок через LocalStorage, серверное время, облачное сохранение, серия из семи дней, рулетка с двумя источниками награды и компактные функции без копирования действий. Для небольшого Construct 3 проекта это уже крепкая основа, которую легко развивать дальше.

Скачать и ознакомится с исходником вы можете здесь