HexThrees — моя первая законченная игра

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

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

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

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

a7a8a379e583be332381403d0bd3a6f1.jpeg

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

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

f8d229a438f0237f545fc8ddd4d47a98.gif

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

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

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

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

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

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

Итераторы

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

4edef70e0ac5c4f13c27480ce1813d5e.jpg

Конечно, двумерный массив проще всего перебирать двойным циклом, прямо как в седьмом классе на уроках 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

© Habrahabr.ru