Как я в одиночку сделал мобильную игру на iOS — LootShrine

от автора

Всем привет! Хочу поделиться своим первым опытом в разработке мобильной игры для 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: эффекты из двух частей

Каждый эффект удара/умения собран из двух слоёв:

  1. Кадровая анимация из атласа — спрайт‑стрип, который проигрывается через SKAction.animate(with: textures, timePerFrame:). Текстуры кэшируются в SKTexture заранее, чтобы не собирать их на лету.

  2. Партиклы 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 на странице игры есть видео геймплея и другие скриншоты.

Скриншоты игры
Основной экран боя

Основной экран боя
Святилище и Лагерь

Святилище и Лагерь
Жертва предметов для силы

Жертва предметов для силы
Выбор персонажа

Выбор персонажа

ссылка на оригинал статьи https://habr.com/ru/articles/1058732/