Привет, Хабр! Одно время здесь были весьма популярны статьи "вот моя первая игра". В последнее время я что-то их не наблюдаю, так что решил восполнить этот пробел самостоятельно.

История создания и геймплей

Игру, созданную самостоятельно от начала до конца я хотел сделать очень давно - начал ещё году в 2012ом с top-down шутера на java под android. Небыстро поняв, что такое в одиночку не потянуть, через пару лет переключился на миниатюрную стратегию на C++. Через год или около того интерес пропал и к ней, и, хоть я иногда и возвращался к этим проектам, всерьёз я понимал, что мне их никогда не закончить. И тогда я начитался статей для новичков геймдева и решил пойти по пути наименьшего сопротивления: быстренько склонировать что-то известное. Только такой путь позволил бы наконец довести хоть что-нибудь до логичного конца.

За основу была взята игра Threes, а точнее, её клон 2048, в который я залипал тогда очень крепко. В качестве оригинальной фичи было решено сделать поле не квадратным, а гексагональным. А ещё и с возможностью выбирать его размер. А чтобы сделать совсем непохоже ни на Threes, ни на 2048, принцип совмещения ячеек сделать динамическим - пользователь сам волен выбирать, нравится ему тройки гонять или степени двоек.

В процессе написания игры я пришёл к выводу, что пользователь, на самом деле, неумён и доверять ему столь важные геймплейные решения было бы весьма опрометчиво. Поэтому размер поля стал гибким: с самого начала предоставляется минимальное 3х3, а затем потихоньку всё увеличивается до максимальных 7х7 (впрочем, шанс выпадения бонуса, который увеличивает поле, обратно пропорционален количеству открытых клеток, так что всё поле не откроет никто). Сам принцип же совмещения игровых фишек мне пришёл во сне, когда я спал на очередной планёрке в начале scrum-ной двухнедельки. Каждую задачу на работе мы оцениваем на сложность по числам Фибоначи от одного до восьми: 1, 2, 3, 5, 8 (условные человекочасы, ценность которых устаканивается для каждой команды).

"Отличная идея!", подумал я, и добавил это в свой хобби проект. Теперь направление движений плиток тоже имеет значение: 1 может "въехать" в 2, но не в обратную сторону. А поскольку 8 это уже степень двойки, то начиная с неё все значения просто будут удваиваются.

Увереный в собственной гениальности, я показал прототип другу. "Ок, и что дальше?" - спросил он. И я задумался.

Действительно, чтобы ещё добавить в и без того идеальную игру? Конечно, бонусы! И таймер, чтобы подстегнуть зазевавшегося пользователя (в итоге я сделал его отключаемым). В качестве бонусов я добавлял все пришедшие в голову идеи, которые могли бы помочь игроку не застрять в этой игре. Вышло не так уж и много: расширение поля, пауза таймера, открытие заблокированных клеток и удаление фишки с поля. Да, и время от времени вместо бонусов игроку выпадает замок, который блокирует одну из клеток. То, что это плохо для игрока, игрок должен понять интуитивно.

Быстро текст пишется, да небыстро баги вылавливались. И если в начале код был лёгок, имел относительно чёткую структуру Model - ViewController - Command, то под конец чёткий запах спагетти могли почувствовать даже те, кто лишь мимолётом взглянул в экран ноутбука из-за моего плеча в поезде, где и писалось большинство кода. Поскольку проект изначально имел главную цель дожить до релиза, то о качестве и чистоте кода я задумывался не слишком. Не добавляло радости и то что swift для меня совершенно не родной язык и в некоторых местах не хватало C# с основной работы. Единственная попытка рефакторинга, которую я предпринял, заключалась в том, что все команды к игре генерировались бы через фабрику, интерфейс к которой легко должен был бы заменяться и, в теории, тестирование отдельных команд должно было быть гораздо проще. На практике я потратил на это почти месяц, это отбило желание писать на Swift ещё на пару месяцев, половину из сделанного я откатил отбратно, а взорванные сопли провалившегося рефакторинга до сих пор видны то тут, то там.

Итак, геймплей готов. К нему добавить немного шейдеров, чуть геометрии для сглаживания хексагонов, motion blur, haptic feedback, страницы помощи, кнопки для тех, кто не любит свайпать, туториал, иконки (нарисованы профессиональным программистом), страницу в app store со всеми скриншотами, видео-превью, растянутое ffmpeg'ом под все расширения, privacy policy, отдельный сайт, чтобы как у людей - в общем, самая минимальная игра-головоломка, которую я только мог придумать, заняла больше двух лет моей жизни. Я не считал конкретные часы в первый год разработки, но довести идею от сырого прототипа до полностью готовой и выложенной в магазин игры заняло 238 часов. Разумеется, это была не основная моя работа и даже не основное хобби, но всё равно это приличный срок для клона мобильной игры.

Я изначально категорически не хотел встраивать рекламу в свою игру, уж больно меня раздражает реклама в мобильных играх (особенно там, где её нельзя отключить). Делать версию платной я тоже не хотел, чтобы дать возможность ознакомиться с геймплеем до покупки. В итоге выставил счётчик очков, после которого игроку предлагается купить единовременно полную версию или сбросить игру заново.

Это всё-таки Хабр, поэтому немного кода.

Итераторы

Хотя игровое поле и представляет из себя хексагональную плоскость, внутри все координаты двумерные, по диагональным осям.

Конечно, двумерный массив проще всего перебирать двойным циклом, прямо как в седьмом классе на уроках qBasic. Но есть проблема: алгоритм для движения и объединения фишек, который проще всего написать и протестировать, оперирует с одномерным массивом - линией. И, к тому же, иногда поле прерывается заблокированной клеткой и тогда одна линия превращается в две. Довершает всё то, что клетки могут двигаться по трём осям в двух направлениях. Так что вместо простого двойного цикла пришлось делать шесть итераторов: XUp, XDown, YUp, YDown, Left and Right, каждый из которых возвращает отрезок, на котором можно уже и запускать игровую логику. Для примера код одного из итераторов с комментариями:

class BaseCellsIterator {
    internal var line = LineCellsContainer() // Текущая линия

    internal var x: Int = 0     
    internal var y: Int = 0

    internal var w: Int { self.gameModel.field.width } // Ширина поля
    internal var h: Int { self.gameModel.field.height } // Высота поля
}

class MoveXDownIterator: BaseCellsIterator, CellsIterator {
    
  	func next() -> LineCellsContainer? {
        line.clear() // Очистим контейнер

        if x >= w { // Если дошли до "правого" края поля, перемещаемся выше
            x = 0
            y += 1
        }

        if y >= h { // Дошли до "левого" края поля, выше некуда, это конец
            return nil
        }

        // Это хитрый способ написать классический цикл for(; x <= w; x++) до "правого" края поля
        for _ in x ..< w {
            defer { x += 1 } 

            guard let cell = getCell(x, y), 
                !cell.isBlocked,
                !cell.isBlockedFromSwipe
            else { break } // Проверка на то, что текущая клетка не рвёт цепочку

            line.add(cell)
        }

        return line
    }
}

Итераторы по диагонали выглядят ещё "интереснее", можно посмотреть здесь. Я думаю, переписать двойной цикл в итератор было бы занятным заданием на интервью.

Иконки

Для меня они вышли настоящим мучением. Наверное, нужно было привлечь кого-нибудь из знакомых художников и попросить нарисовать, но я изначально хотел всё сделать сам. Пока делал логику, иконками служил пак котиков, который когда-то давно выложили на хабре (за давностью лет никак не могу найти на него ссылку, а этот набор уже не в одном проекте мне служил добрую службу графических placeholder'ов). Когда пришло время нарисовать настоящие картинки для бонуса, я пытался их сделать в фотошопе, потом в векторных редакторах, пытался нарисовать от руки и отсканировать. Выглядело это хуже верблюжьих фекалий.

В итоге лучший вариант который я нашёл - сделать их так же, как и всю остальную игру - в коде. Как оказалось, SpriteKit вполне себе неплохо работает даже в консольной утилите, надо только рендерить их не на экран, а в текстуру, которую затем сохранять на диск, а геометрию можно использовать из основной кодобазы. Если сделать SKNode того, что нужно нарисовать (главное - не использовать абсолютные координаты, а скейлить от какого-то базового размера, чтобы затем удобно было иконку подгонять под нужное разрешение), то затем подобная функция сохраняет файлы на диск:

    public func renderNode(node: SKNode, filename: String) throws {
        let destinationURL = URL(fileURLWithPath: filename, isDirectory: false) as CFURL

        guard let texture = view.texture(from: node) else { throw ImageGeneratorError.textureRenderFailed }

        let image = texture.cgImage()
// Почему здесь "public.png" в качестве Uniform Type Identifier, я, признаюсь, не помню и не знаю. 
        guard let destination = CGImageDestinationCreateWithURL(destinationURL, "public.png" as CFString, 1, nil) else { throw ImageGeneratorError.destinationCreationFailed }

        let imageProperties = [kCGImageDestinationLossyCompressionQuality as String: 0.8]

        CGImageDestinationAddImage(destination, image,imageProperties as CFDictionary)
        let result = CGImageDestinationFinalize(destination)

        let date = Date()
        let calendar = Calendar.current
        let hour = calendar.component(.hour, from: date)
        let minutes = calendar.component(.minute, from: date)
        let time = "\(hour):\(minutes)"

        print(result
            ? "\(filename) rendered successfully at \(time)"
            : "\(filename) render FAILED at \(time)")
    }

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

Итог

Где-то месяц назад я наконец-то выложил игру в App Store, скинул ссылки друзьям, опубликовал пару постов на реддите. Как итог - игру скачали 37 раз, из них 6 раз купили. При этом я был одним из купивших. Получение прибыли не было целью этого хобби-проекта, но в материальном плане я несколько разочарован.

С другой стороны, ощущение, когда то, что писал по вечерам и в поездах по дороге на работу - работает, закончено, и с этим кто-то играет - бесценно.Я не просто было одним из команды, разрабатывающей новый проект, я сделал и завершил это полностью сам. Много лет я делал какие-то поделки, которые всегда шли в стол и только сейчас что-то из этого увидело свет. Потешить своё самолюбие программиста дорогого стоит.

Если подвести итоги более структурировано:

  • Инди игры не приносят денег без маркетинга и ими стоит заниматься только в качестве хобби.

  • Знания не бывают лишними. Конструкции языка, которые я узнал из Swift впоследствии я позднее обнаружил и в С#8/9.

  • Довести проект до релиза это не то же самое что и запилить прототип. Чем меньше остаётся вещей, которые нужно доделать, тем сложнее над ними работать. Происходит большая недооценка сложности ("ну на скриншоты у меня уйдёт полчаса") и сильная потеря мотивации.

  • Я понял, что работать full-stack девелопером для меня куда интереснее, чем перейти на позицию ios-разработчика. Тем не менее, любая технология надоедает, если решать задачи только с её помощью, поэтому разделение "на работе .net, а потом часик в Swift" помогает продуктивности в обоих направлениях.

И самый главный результат - теперь у меня есть, наконец, игра, в которую лично мне интересно позалипать в поезде по дороге с работы. Жаль только, я уже год как работаю из дома.

Ссылка на GitHub