Всем привет! Хочу поделиться своим первым опытом в разработке мобильной игры для iOS — в жанре roguelite. Это pet‑проект, который неожиданно для меня самого дорос до релиза в App Store, так что расскажу и про идею, и про технические идеи. Надеюсь, будет интересно почитать:)
С чего всё началось
Я проходил курс по разработке под iOS и решил, что лучший способ реально закрепить материал — сделать несколько реальных проектов. Одним из таких проектов стал LootShrine — ссылка на игру в App Store.
Это рогалик (roguelite) с упором на менеджмент инвентаря и пошаговые бои: есть несколько режимов (бесконечный и условно «сюжетный» на 100 этажей) поднимаешься по башне из 100 этажей, собираешь лут, экипируешь или жертвуешь предметы в святилище ради перманентной силы на забег. Каждый предмет — это выбор: надеть, «поглотить» ради временного баффа в бою, или пожертвовать. Отдельный привет вдохновившей меня Megaloot (в целом отчасти идея сделать такую игру от нее и пошла, так как на мобильных устройствах ничего похоже не обнаружил):)
Дальше — про то, как это устроено внутри.
Стек и архитектура
Сразу оговорю одно из ключевых ограничений, которое я поставил себе изначально: никакого своего backend — только внутренние сервисы Apple, так как не хотелось усложнять через чур (да и дорого сейчас). Это осознанное решение (для соло‑проекта нет ни времени, ни желания поднимать и поддерживать еще и серверную часть), и оно во многом определило всю архитектуру: сохранения, синхронизация, соревновательная часть — всё завёрнуто в CloudKit и Game Center (подробнее ниже).
Базовый стек — чистый SwiftUI (iOS 17+/macOS 14+), без сторонних UI‑фреймворков. Внешних SPM‑зависимостей всего две: RevenueCat для внутриигровых покупок и TelemetryDeck для аналитики. Про TelemetryDeck отдельно отмечу — это privacy‑first решение: собирается только анонимная агрегированная статистика, без привязки к личности игрока (меньше проблем в ревью приложения в AppStore, да и в целом для игроков проще решиться поставить игру, где нет каких‑то нужных доп. разрешений). Всё остальное — нативный стек Apple.
Архитектурно я пошёл в Redux‑подобный подход, а не в MVVM или какой‑то готовый движок (хотя этот вариант сильно упростил бы конечно разработку в некоторых моментах:) ), и вот почему это оказалось удачно для игры (по крайней мере так мне кажется):
Единый источник правды. Всё состояние игры лежит в одной Codable‑структуре GameState (внутри — под‑состояния: RunState, PlayerState, BattleState, ShopState, MetaState и так далее). Для игры это критично: десятки экранов должны видеть согласованное состояние.
Действия описывают события. Есть один enum GameAction, который перечисляет вообще всё, что может произойти в игре: playerAttack, purchaseItem, sacrificeItem, advanceFloor, endRun и так далее.
Редьюсеры — чистые функции вида (State, Action) → State, без сайд‑эффектов: никакого I/O, сети или рандома со скрытым состоянием внутри. Пошаговый бой прекрасно ложится на модель «состояние → действие → новое состояние».
Стор на @Observable. Использую нативный Observation из iOS 17 (@Observable), а не ObservableObject/Combine — меньше бойлерплейта и точечная инвалидация вьюх.
View только диспатч. View никогда не мутирует состояние напрямую, только store.dispatch(.action).
Отдельно про оркестрацию редьюсеров: есть корневой gameReducer, который принимает действие и делегирует его в специализированные под‑редьюсеры — BattleReducers, ShopReducers, ShrineReducers, RunReducers, EventReducers, MetaReducers и др. То есть корневой редьюсер — это диспетчер, а вся доменная логика разложена по модулям. Такой распил здорово помогает, когда логика боя разрастается: файл боевого редьюсера у меня уже на несколько тысяч строк, и без разбиения это было бы адом.
Контент (предметы, враги, сеты, зоны) частично лежит в JSON, частично — в декларативных Swift‑таблицах. Сайд‑эффекты (сохранения, звук, генерация предметов, Game Center, облако) вынесены в отдельный слой сервисов.
Рендеринг спрайтов: атласы поверх SwiftUI
Вся пиксель‑графика — врагов, предметов, иконок скиллов — рендерится в SwiftUI нарезкой из больших текстурных атласов. То есть я не кладу тысячи отдельных PNG (в отдельных моментах все же у меня есть и отдельные png, jpg ассеты, но атласы основной подход), а держу большие листы‑атласы и вырезаю нужный кадр: Image(uiImage:) с interpolation(.none) (чтобы пиксель‑арт оставался чётким) и смещением/кадрированием по координате спрайта в атласе. Так сделаны и спрайты мобов, и сетки иконок предметов, и иконки веток скиллов.
Важный момент: сами спрайты — это SwiftUI. SpriteKit я подключил позже и только для одного слоя — визуальных эффектов.
Откуда взялся арт: ChatGPT + Aseprite
Я конечно не художник и не арт‑дизайнер, но про «пайплайн графики» тоже немного расскажу. Спрайты, иконки, статусные эффекты и графику я генерил в ChatGPT, а дальше доводил руками: центрировал, чистил, приводил к единому размеру и собирал в текстурные атласы в Aseprite (редактор пиксель‑арта). Те самые большие атласы, о которых я писал выше — это как раз результат ручной сборки в Aseprite. Генерация даёт заготовки быстро, но без ручной доводки и аккуратной раскладки по атласу в игре это не заводится.
Тонкий момент № 1: почему появился слой SpriteKit
Изначально игра была полностью на SwiftUI, включая эффекты ударов, смертей и тому подобное — я рисовал их SwiftUI‑оверлеями поверх боя. И тут вылезла проблема: тайминги анимаций в SwiftUI ненадёжны (ну либо я не умею их готовить), потому что завязаны на жизненный цикл вью. Эффект мог оборваться на середине, проиграться не вовремя или не проиграться вовсе при перерисовке.
Тут я немного подумал, и решил — вынести весь боевой VFX в прозрачную SKScene (SpriteKit), наложенную поверх SwiftUI‑боя. SpriteKit даёт надёжный SKAction.sequence с предсказуемым таймингом, не зависящим от перерисовок SwiftUI. По сути, одна сцена заменила пачку SwiftUI‑оверлеев (эффекты атак + «пуфы» смерти).
Но появился мост между двумя слоями, и это как раз то самое узкое место, которое я планирую рефакторить (так как даже на такой простой графике съедает довольно много ресурсов):
SwiftUI отвечает за расположение спрайтов на экране. Их координаты я измеряю средствами SwiftUI (общее координатное пространство / GeometryReader) и передаю в SKScene как позиции, куда бить эффектом.
Внутри сцена конвертирует координаты из системы SwiftUI в систему SpriteKit (у SpriteKit ось Y перевёрнута — начало координат снизу).
Более того, эффекты с «привязкой» — например капающий яд/кровотчение/DOT — следуют за спрайтом покадрово: сцена в каждом кадре (update) перепозиционирует эмиттеры под текущие координаты «плавающих» спрайтов.
Работает — но по сути у меня два рендер‑конвейера (SwiftUI + SpriteKit) живут рядом и синхронизируются через прокидывание координат каждый кадр. Это и есть точка для оптимизации: хочу переехать на более производительную и цельную схему, чтобы не гонять позиции туда‑сюда.
Тонкий момент № 2: эффекты из двух частей
Каждый эффект удара/умения собран из двух слоёв:
Кадровая анимация из атласа — спрайт‑стрип, который проигрывается через SKAction.animate(with: textures, timePerFrame:). Текстуры кэшируются в SKTexture заранее, чтобы не собирать их на лету.
Партиклы SpriteKit — SKEmitterNode поверх (искры, брызги, капли DOT и тому подобное).
Крит, кстати, — это отдельная накладываемая анимация поверх основного удара, а «убийство» — это последовательность: сначала проигрывается эффект атаки, а после его завершения — эффект смерти. Всё это fire‑and‑forget: ноды сами себя убирают после проигрывания.
Тонкий момент № 3: «дыхание» и полёт мобов
Мобы не статичные — у них есть idle‑анимация, кастомная SwiftUI‑анимация/ViewModifier:
«Дыхание» — это лёгкое неравномерное масштабирование спрайта (стретч), заякоренное у «ног» спрайта, чтобы моб как будто дышал, а не прыгал. Сначала я делал простой вертикальный «боб» (подпрыгивание), но он читался мультяшно и перебивал дыхание — в итоге оставил именно дыхание как более «дорогой» на вид вариант.
Летающие враги поверх дыхания получают hover‑смещение — модификатор добавляет органическое парение по 2D‑траектории.
То есть это всё честный SwiftUI‑слой анимаций поверх атласных спрайтов, без SpriteKit.
Бэкенда нет: CloudKit + Game Center
Как и писал в начале, у игры нет собственного бэкенда — всё построено на нативном стеке Apple.
Game Center (GameKit) отвечает за соревновательную часть:
Таблицы лидеров — их больше 70 (глобальные и по классам, по разным метрикам: этаж, боссы, золото, души и так далее).
Достижения — 75+.
Отправка очков сделана отказоустойчиво: если сети нет или сабмит упал, результат кладётся в офлайн‑очередь и переотправляется позже (с персистом), чтобы ничего не терялось.
CloudKit отвечает за всё, что связано с данными игрока и социалкой (тут именно CloudKit, а не Game Center):
Облачные сохранения и их синхронизация между устройствами (для family‑shared устройств: если человек купил полную версию игры, то на устройствах членов семьи — игра также будет в полной версии).
Ghost‑челленджи — можно бросить вызов другу шареным кодом; забег‑«призрак» идет через публичную базу CloudKit, и игроки «играют» друг с другом.
Hall of the Fallen — публичный пул недавно погибших забегов других игроков, который можно «переплюнуть», без кодов и друзей.
Профили игроков (косметика/аватары для отображения в лидербордах).
Такой подход дал бесплатную и надёжную инфраструктуру без серверной части — для соло‑разработчика это огромная экономия сил.
Бонус‑вызов: обратная совместимость сохранений
Так как игра уже в релизе и у людей есть сейвы, а игру я все еще поддерживаю и обновляю, добавляю новый контент — любое изменение схемы состояния может сломать чужой прогресс. Codable тут оказался коварен (пару раз ломал локально себе сейвы, но без выкатки в прод): если добавить новое не‑опциональное поле в структуру состояния, декодер старого сейва падает с keyNotFound, даже если у свойства есть дефолт. Поэтому я выработал для себя железное правило: новые поля в состоянии — только Optional? с = nil и чтением через?? <дефолт>.
Инструменты и AI
Ну и конечно при разработке не обошлось без агентов (прастите) — я использую Claude от Anthropic. Плюс ChatGPT для генерации арта (см. выше). AI хорошо ускоряет рутину и черновики, но всю архитектуру, сборку атласов и доводку баланса всё равно приходится держать в голове и делать руками.
Что в итоге
Из учебного пет‑проекта на курсе получилась настоящая игра в App Store: соло‑разработка, нативный стек, Redux‑архитектура, гибрид SwiftUI + SpriteKit и вся инфраструктура на CloudKit + Game Center без своего бэкенда. По пути я на практике прочувствовал кучу вещей — от границ SwiftUI в анимациях до эволюции схемы данных в проде.
Если стало интересно — можно заглянуть по ссылке на App Store в начале статьи. Если есть вопросы, то с радостью на них поотвечаю. Спасибо, что дочитали!:)
P. S. В спойлере далее несколько скриншотов игры, но также в App Store на странице игры есть видео геймплея и другие скриншоты.