Ежедневная рулетка наград в 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 = 1. У второго ставим 2, у третьего 3 и так далее.
Плашка reward_available сделана спрайтом с двумя кадрами: награда недоступна и награда доступна. Это удобнее, чем каждый раз менять текст, фон и декоративные элементы по отдельности.

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

После создания базы копируем её URL со вкладки Data. В проекте он записан в переменную FirebaseBaseUrl без завершающего слеша:
https://fir-dailyreward-default-rtdb.europe-west1.firebasedatabase.app

REST API Firebase работает с окончанием .json, поэтому путь конкретного игрока будет собираться так:
FirebaseBaseUrl & "/players/" & PlayerID & ".json"
То есть данные лежат по адресу /players/PlayerID.json.
Для учебного проекта можно разрешить чтение и запись узла игрока, но при этом проверить структуру данных:

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

Это простой способ обойтись без формы входа. Но очистка данных браузера создаст нового игрока, а перенести прогресс на другое устройство не получится. Для учебной механики такое ограничение нормально.
Получаем серверное время
Системное время устройства использовать нельзя: пользователь может перевести дату вперёд и забрать ещё одну награду. Кто играл в старые мобильные игры, тот наверняка помнит этот «секретный календарный чит».
На старте отправляем AJAX-запрос:
https://time.now/developer/api/timezone/Etc/UTC
После ответа парсим AJAX.LastData через объект JSON, забираем поле unixtime и сохраняем его в ServerUnix:
int(JSON.Get("unixtime"))

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 нам не нужен.

После успешного 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

Здесь CanClaimReward = 0, а в отладку выводится оставшееся время в секундах.
Прошло от 24 до 48 часов
В промежутке от 86400 до 172800 секунд игрок сохранил серию и может получить следующий бонус:
LastRewardUnix > 0
& SecondsSinceReward >= 86400
& SecondsSinceReward < 172800

Ставим CanClaimReward = 1. Значение CurrentDailyBonus уже рассчитано по следующему дню серии.
Прошло 48 часов или больше
Если игрок пропустил окно:
SecondsSinceReward >= 172800
сбрасываем DailyStreak в 0, разрешаем награду и возвращаем бонус первого дня — 10.

Максимальная серия равна семи. После седьмого дня значение остаётся 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)

По сути, функция синхронизирует весь интерфейс с текущими переменными. Формула для lockedIcon сравнивает серию с dayIndex каждого экземпляра и переводит результат в кадр 0 или 1.
Если захотите показывать сумму ближайшего бонуса в отдельном тексте, логично добавить обновление этого текста сюда же. В текущем экспорте функция меняет только StrikeDayValue, reward_available и lockedIcon.
SavePlayerToFirebase
Вторая функция собирает актуальные значения в JSON и отправляет PUT:
{
"coins": Coins,
"dailyStreak": DailyStreak,
"lastRewardUnix": LastRewardUnix
}

Точный запрос из событий уходит на:
FirebaseBaseUrl & "/players/" & PlayerID & ".json"
Так событие завершения рулетки отвечает только за игровую логику, а детали HTTP-запроса лежат в одном месте. Если структура игрока изменится, править сохранение придётся только в функции.
Проверка результата
Для первого теста удобно оставить debug_text на экране и пройти такой сценарий:
- Очистить
player_idв LocalStorage и запустить макет. - Убедиться, что создан
player_XXXXXXи запись появилась в/players. - Проверить, что первая награда доступна и даёт бонус
10. - Прокрутить рулетку и сверить
coins,dailyStreak = 1иlastRewardUnixв Firebase. - Перезапустить макет: рулетка должна быть заблокирована, пока не пройдёт
86400секунд. - Для ускоренного теста вручную изменить
lastRewardUnixв Firebase на время старше 24 и 48 часов и проверить обе ветки. - Дойти до
DailyStreak = 7и убедиться, что серия больше не растёт, а бонус остаётся150.
Отдельно проверьте ошибки сети. Если не пришло серверное время или данные Firebase, флаги готовности останутся нулевыми и награда не должна выдаваться.
Что можно улучшить дальше
Первым делом я бы добавил обратный отсчёт до следующей попытки. Нужное значение уже есть:
86400 - SecondsSinceReward
Дальше можно подключить Firebase Authentication, перенести критичные расчёты на доверенный сервер, добавить повтор запросов при сетевых ошибках и сохранить ожидающее обновление локально. Ещё один полезный шаг — не только менять кадр плашки, но и визуально отключать кнопку.
Но даже в текущем виде мы получили законченную связку: постоянный игрок через LocalStorage, серверное время, облачное сохранение, серия из семи дней, рулетка с двумя источниками награды и компактные функции без копирования действий. Для небольшого Construct 3 проекта это уже крепкая основа, которую легко развивать дальше.
Скачать и ознакомится с исходником вы можете здесь