Sapper: Royal Engineer

    Хабраразработчики, приветствую!

    В данном посте я расскажу «историю» разработки и публикации первой нашей игры: как рисовался дизайн, как разрабатывали, с какими трудностями столкнулись, почему StackOverflow лучше Apple Dev Forums и т.д.
    Игра делалась с целью формирования механизмов взаимодействия с дизайнером, для последующего ускорения разработки на более сложных играх, поэтому судите строго (на столько, насколько это возможно).

    Картинки для привлечения внимания:
    image image

    image image



    С чего всё началось?


    Так получилось, что на новой работе, по определенным причинам, пришлось заниматься разработкой игр для автоматов. Работал я с талантливым дизайнером, который очень сильно хотел разрабатывать другие игры (на мобильные платформы, под PC, XBox и т.д). Вот мы с ним и решили параллельно разработать что-то интересное, но в то же время не слишком сложное, чтобы наша разработка не затянулась на 4-5-6 месяцев. Ни я, ни он не были готовы к такому затяжному прыжку.

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

    Вот, за что мы хотели браться, но рады очень, что вовремя верно оценили наши силы:

    Скриншоты
    image

    image

    image

    image


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

    На StackOverflow я видел, что очень активно поддерживается Cocos2D самим автором (LearnCocos2D), но мне очень хотелось попробовать именно SpriteKit после презентации Apple и показа игры Adventure. Огорчил меня правда тот факт, что в XCode встроена визуализация частиц, а я XCode хочу реже открывать.

    Инструменты


    На начальных этапах связка XCode + AppCode, Photoshop.
    Потом только AppCode и Photoshop.

    Про SpriteKit можно посмотреть здесь или здесь.

    Дизайн (ретина, не ретина, 4 и 5 iPhone)


    Я сразу был за то, чтобы 4 iPhone мы не поддерживали и не парились с еще кучей изображений, которых у нас и так было достаточно из-за особенностей локализации приложения. Раз отказались от 4 и всего, что ниже, значит рисовать надо только под ретину — отлично!

    Вот, например, как выглядел наш первый вариант:

    Скриншоты





    Дальше мы задавались примерно такими вопросами:
    • Должна ли быть реклама на игровом экране и закрывать игровое поле?
    • Учитывать ли при отрисовке фонового изображения размеры и положение рекламного блока?
    • Должна ли быть кнопка «Меню» или же кнопка «Назад»?
    • Что показывать после того, как пользователь выиграл или проиграл?
    • и т.д.


    Следующие уже версии выглядели примерно так:

    Скриншоты







    Давайте дизайнеру волю, но контролируйте. Дело в том, что шрифта, который он использовал для надписей затраченного времени и кол-ва бомб на поле, нет в стандартном списке, значит надо искать в интернете. Но это еще ничего, дело в том, что надо еще найти _правильный_ шрифт с учетом того, что за надписью находится фоновое изображение с 4 серыми цифрами с определенными расстояниями между ними, а в большинстве случаев мы сталкивались с тем, что при изменении надписи с «111» до «888» ширина текстовой надписи (UILabel) изменялась и менялось само расстояние между символами, что нас не устраивало… однако, нужный шрифт был найден, слава Богу, иначе пришлось бы делать 10 изображений, позиционировать их и обновлять счетчик соответствующим образом. Казалось бы, простой шрифт, но увы, не всё так просто (в разделе «Разработка» расскажу, что еще интересного было со шрифтом этим).

    Со спрайтами проблем не было никаких. Больше всего нам доставлял удовольствие вот этот переключатель:



    Три вещи над которыми дизайнер дольше всего работал:
    • Фоновое изображение главного экрана
    • Фоновое изображение экрана настроек
    • Анимация взрыва бомбы


    Анимация взрыва бомбы содержит порядка 40 кадров (на скриншоте ниже два типа взрыва).



    Столкнулись мы с дизайнером еще с одной проблемкой — позиционирование элементов и указание позиций. Для него это совершенно не принципиально, пиксель влево, пиксель вправо — не имеет значения… он рисует всё без линеек, уж такой вот творческий человек :)
    Меня этот вариант совсем не устраивал, потому что позиционировать мне как-то надо, а значит нужны хоть какие-то координаты/размеры.

    Получилось как-то так:



    Удобно, но что-то здесь не так, меня не покидает такое чувство.

    Звуки



    Со звуками тоже пришлось разбираться дизайнеру. Мы нашли подходящий сайт www.freesound.org и использовали некоторые звуки (совсем без обработки не получилось — обрезание, фильтрация):

    • Взрыв
    • Откапывание
    • Нажатие на любой «кнопочный» элемент


    Разработка



    Начиналось всё со сторибордов:



    Закончилось:



    Проектирование, проектирование, проектирование… херня полная. Создаешь каркас, а дальше на него начинаешь наворачивать функционал, логику, графику и т.д. От начала разработки до конца у меня структура проекта не менялась, но схемы взаимодействия и логики работы контроллеров — множество раз.

    Начнем с главного экрана. Две кнопки, по тапу осуществляется переход на экран игры и в настройки. Барабанная дробь… по тапу еще проигрывается звук, а значит здесь либо SystemSound, либо AVAudioPlayer (либо еще что-то), а значит нужна предзагрузка, а значит нужен еще какой-то класс, который бы отвечал за предзагрузку всех звуков и их воспроизведение. Так и получилось — BGAudioPreloader.

    @interface BGResourcePreloader : NSObject <AVAudioPlayerDelegate>
    
    + (instancetype)shared;
    
    // предзагружает аудио файл и готовит его к проигрыванию
    - (void)preloadAudioResource:(NSString *)name;
    
    // возвращает аудиопроигрыватель для воспроизведения аудио файла с именем name и
    // расширением type
    // nil - если звуки отключены
    - (AVAudioPlayer *)playerFromGameConfigForResource:(NSString *)name;
    
    // возвращает аудиопроигрыватель для воспроизведения аудио файла с именем name и
    // расширением type. Не зависит от настроек звука
    - (AVAudioPlayer *)playerForResource:(NSString *)name;
    
    @end
    


    Реализация вот такая:
    //
    //  BGResourcePreloader.m
    //  Miner
    //
    //  Created by AndrewShmig on 4/5/14.
    //  Copyright (c) 2014 Bleeding Games. All rights reserved.
    //
    
    #import "BGResourcePreloader.h"
    #import "BGSettingsManager.h"
    
    
    @implementation BGResourcePreloader
    {
        NSMutableDictionary *_data;
    }
    
    #pragma mark - Class methods
    
    static BGResourcePreloader *shared;
    
    + (instancetype)shared
    {
        static dispatch_once_t once;
    
        dispatch_once(&once, ^{
            shared = [[self alloc] init];
            shared->_data = [[NSMutableDictionary alloc] init];
        });
    
        return shared;
    }
    
    #pragma mark - Instance methods
    
    - (void)preloadAudioResource:(NSString *)name
    {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
            NSString *soundPath = [[NSBundle mainBundle]
                                             pathForResource:name
                                                      ofType:nil];
            NSURL *soundURL = [NSURL fileURLWithPath:soundPath];
            AVAudioPlayer *player = [[AVAudioPlayer alloc]
                                                    initWithContentsOfURL:soundURL
                                                                    error:nil];
            [player prepareToPlay];
    
            _data[name] = player;
        });
    }
    
    - (AVAudioPlayer *)playerFromGameConfigForResource:(NSString *)name
    {
        //    звуки отключены
        if ([BGSettingsManager sharedManager].soundStatus == BGMinerSoundStatusOff)
            return nil;
    
        return [self BGPrivate_playerForResource:name];
    }
    
    - (AVAudioPlayer *)playerForResource:(NSString *)name
    {
        return [self BGPrivate_playerForResource:name];
    }
    
    #pragma mark - AVAudioDelegate
    
    - (void)audioPlayerBeginInterruption:(AVAudioPlayer *)player
    {
        [player stop];
        player.currentTime = 0.0;
    }
    
    #pragma mark - Private method
    
    - (AVAudioPlayer *)BGPrivate_playerForResource:(NSString *)name
    {
        return (AVAudioPlayer *) _data[name];
    }
    
    @end
    
    


    На главном экране больше ничего интересного.

    Переходим к экрану настроек.

    У нас тут сразу UISegmentedControl (похожий) и переключатель (UIButton).
    Перед тем, как писать велосипед с собственным UISegmentedControl я очень тщательно порыл StackOverflow и понял, что лучше не наследоваться, а писать всё-таки велосипед… ничего сложного, но кое-какие особенности есть (механизм работы переключателя таков, что даже водя пальцев по нему, опция активая изменяется и зависит не только от того, где вы подняли палец, но и от того, где сейчас ваш палец находится).

    Основная обработка изменения состояния переключателя выглядит следующим образом:

    #pragma mark - Touches
    
    - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
    {
        [self updateSegmentedControlUsingTouches:touches];
    }
    
    - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
    {
        [self updateSegmentedControlUsingTouches:touches];
    }
    
    - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
    {
        [self updateSegmentedControlUsingTouches:touches];
    }
    
    #pragma mark - Private method
    
    - (void)updateSegmentedControlUsingTouches:(NSSet *)touches
    {
        UITouch *touch = [touches anyObject];
        CGPoint touchPoint = [touch locationInView:self];
    
        for (NSUInteger i = 0; i < _selectedSegments.count; i++) {
            CGRect rect = ((UIImageView *) _selectedSegments[i]).frame;
    
            if (CGRectContainsPoint(rect, touchPoint)) {
    
                if (self.selectedSegmentIndex != i) {
                    //    проигрываем звук нажатия - единожды и только на новом
                    //                значении
                    [[[BGResourcePreloader shared]
                                           playerFromGameConfigForResource:@"buttonTap.mp3"]
                                           play];
                }
    
                self.selectedSegmentIndex = i;
    
                break;
            }
        }
    
        [_target performSelector:_action
                      withObject:@(_selectedSegmentIndex)];
    }
    


    Вопросов не возникает.

    Любимый наш элемент — переключатель. Сперва он работал как обычная кнопка, но меня это постоянно бесило потому, что ощущения не те и хочется чувствовать, что он настоящий и работает так, как это делает настоящий.

    В итоге получил следующий код для переключения между состояниями:
    #pragma mark - Touches
    
    - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
    {
    //    не надо обрабатывать это нажатие
    }
    
    - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
    {
        BGLog();
    
        [self updateActiveRegionUsingTouches:touches];
    
        if ((self.isOn && self.activeRegion == BGUISwitchLeftRegion) ||
                (!self.isOn && self.activeRegion == BGUISwitchRightRegion)) {
    
            [super touchesMoved:touches withEvent:event];
            [self playSwitchSound];
    
            [_target performSelector:_action withObject:self];
    
            self.on = !self.on;
        }
    }
    
    - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
    {
        BGLog();
    
        [self updateActiveRegionUsingTouches:touches];
    
        if ((self.isOn && self.activeRegion == BGUISwitchLeftRegion) ||
                (!self.isOn && self.activeRegion == BGUISwitchRightRegion)) {
    
            [super touchesEnded:touches withEvent:event];
            [self playSwitchSound];
            [_target performSelector:_action withObject:self];
    
            self.on = !self.on;
        }
    }
    
    - (void)updateActiveRegionUsingTouches:(NSSet *)touches
    {
        UITouch *touch = [touches anyObject];
        CGPoint touchPoint = [touch locationInView:self];
        CGRect leftRect = CGRectMake(0, 0, self.bounds.size.width / 2, self.bounds.size.height);
        CGRect rightRect = CGRectMake(self.bounds.size.width / 2, 0, self.bounds.size.width / 2, self.bounds.size.height);
    
    
        if (CGRectContainsPoint(leftRect, touchPoint)) {
            _activeRegion = BGUISwitchLeftRegion;
        } else if (CGRectContainsPoint(rightRect, touchPoint)) {
            _activeRegion = BGUISwitchRightRegion;
        } else {
            _activeRegion = BGUISwitchNoneRegion;
        }
    }
    


    При каждом изменении состояния мы сохраняем новое значение настроек в менеджере настроек. Менеджер настроек (который в релизнутом приложении) получился дерьмовым, потом я написал новый, но пока не заменил.

    Вот исходный код нового менеджера настроек:
    static NSString* const kBGSettingManagerUserDefaultsStoreKeyForMainSettings = @"kBGSettingsManagerUserDefaultsStoreKeyForMainSettings";
    static NSString* const kBGSettingManagerUserDefaultsStoreKeyForDefaultSettings = @"kBGSettingsManagerUserDefaultsStoreKeyForDefaultSettings";
    
    
    // Class allows to work with app settings in a simple and flexible way.
    @interface BGSettingsManager : NSObject
    
    // Delimiters for setting paths. Defaults to "." (dot) character.
    @property (nonatomic, readwrite, strong) NSCharacterSet *pathDelimiters;
    // Boolean value which specifies if exception should be thrown if settings path
    // doesn't exist or they are incorrect. Defaults to YES.
    @property (nonatomic, readwrite, assign) BOOL throwExceptionForUnknownPath;
    
    + (instancetype)shared;
    
    // creates default settings which are not used as main settings until
    // resetToDefaultSettings method is called
    // example: [[BGSettingsManager shared] createDefaultSettingsFromDictionary:@{@"user":@{@"login":@"Andrew", @"password":@"1234"}}]
    - (void)createDefaultSettingsFromDictionary:(NSDictionary *)settings;
    // resets main settings to default settings
    - (void)resetToDefaultSettings;
    // clears/removes all settings - main and default
    - (void)clear;
    
    // adding new setting value for settingPath
    // example: [... setValue:@YES forSettingsPath:@"user.personalInfo.married"];
    - (void)setValue:(id)value forSettingsPath:(NSString *)settingPath;
    
    // return setting value with specified type
    - (id)valueForSettingsPath:(NSString *)settingsPath;
    - (BOOL)boolValueForSettingsPath:(NSString *)settingsPath;
    - (NSInteger)integerValueForSettingsPath:(NSString *)settingsPath;
    - (NSUInteger)unsignedIntegerValueForSettingsPath:(NSString *)settingsPath;
    - (CGFloat)floatValueForSettingsPath:(NSString *)settingsPath;
    - (NSString *)stringValueForSettingsPath:(NSString *)settingsPath;
    - (NSArray *)arrayValueForSettingsPath:(NSString *)settingsPath;
    - (NSDictionary *)dictionaryValueForSettingsPath:(NSString *)settingsPath;
    - (NSData *)dataValueForSettingsPath:(NSString *)settingsPath;
    
    @end
    


    Часть с реализацией:
    //
    // Copyright (C) 4/27/14  Andrew Shmig ( andrewshmig@yandex.ru )
    // Russian Bleeding Games. All rights reserved.
    //
    // Permission is hereby granted, free of charge, to any person
    // obtaining a copy of this software and associated documentation
    // files (the "Software"), to deal in the Software without
    // restriction, including without limitation the rights to use,
    // copy, modify, merge, publish, distribute, sublicense, and/or
    // sell copies of the Software, and to permit persons to whom the
    // Software is furnished to do so, subject to the following
    // conditions:
    //
    // The above copyright notice and this permission notice shall be
    // included in all copies or substantial portions of the Software.
    //
    // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
    // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
    // IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
    // FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
    // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    // THE SOFTWARE.
    //
    
    #import "BGSettingsManager.h"
    
    
    @implementation BGSettingsManager
    {
        NSMutableDictionary *_defaultSettings;
        NSMutableDictionary *_settings;
    }
    
    #pragma mark - Class methods
    
    + (instancetype)shared
    {
        static dispatch_once_t once;
        static BGSettingsManager *shared;
    
        dispatch_once(&once, ^{
            shared = [[self alloc] init];
            shared->_pathDelimiters = [NSCharacterSet characterSetWithCharactersInString:@"."];
            shared->_throwExceptionForUnknownPath = YES;
    
            [shared BGPrivateMethod_loadExistingSettings];
        });
    
        return shared;
    }
    
    #pragma mark - Instance methods
    
    - (void)createDefaultSettingsFromDictionary:(NSDictionary *)settings
    {
        _defaultSettings = [self BGPrivateMethod_deepMutableCopy:settings];
    
        [self BGPrivateMethod_saveSettings];
    }
    
    - (void)resetToDefaultSettings
    {
        _settings = [_defaultSettings mutableCopy];
    
        [self BGPrivateMethod_saveSettings];
    }
    
    - (void)clear
    {
        _settings = [NSMutableDictionary new];
        _defaultSettings = [NSMutableDictionary new];
    
        [self BGPrivateMethod_saveSettings];
    }
    
    
    - (void)setValue:(id)value forSettingsPath:(NSString *)settingPath
    {
        NSArray *settingsPathComponents = [settingPath componentsSeparatedByCharactersInSet:self
                .pathDelimiters];
        __block id currentNode = _settings;
    
        [settingsPathComponents enumerateObjectsUsingBlock:^(id pathComponent,
                                                             NSUInteger idx,
                                                             BOOL *stop) {
    
            id nextNode = currentNode[pathComponent];
    
            BOOL nextNodeIsNil = (nextNode == nil);
            BOOL nextNodeIsDictionary = [nextNode isKindOfClass:[NSMutableDictionary class]];
            BOOL lastPathComponent = (idx == [settingsPathComponents count] - 1);
    
            if ((nextNodeIsNil || !nextNodeIsDictionary) && !lastPathComponent) {
    
                [currentNode setObject:[NSMutableDictionary new]
                                forKey:pathComponent];
            } else if (idx == [settingsPathComponents count] - 1) {
    
                if ([value isKindOfClass:[NSNumber class]])
                    currentNode[pathComponent] = [value copy];
                else
                    currentNode[pathComponent] = [value mutableCopy];
            }
    
            currentNode = currentNode[pathComponent];
        }];
    
        [self BGPrivateMethod_saveSettings];
    }
    
    - (id)valueForSettingsPath:(NSString *)settingsPath
    {
        NSArray *settingsPathComponents = [settingsPath componentsSeparatedByCharactersInSet:self
                .pathDelimiters];
        __block id currentNode = _settings;
        __block id valueForSettingsPath = nil;
    
        [settingsPathComponents enumerateObjectsUsingBlock:^(id obj,
                                                             NSUInteger idx,
                                                             BOOL *stop) {
    
    //        we have a nil node for a path component which is not the last one
    //        or a node which is not a leaf node
            if ((nil == currentNode && idx != [settingsPathComponents count]) ||
                    (currentNode != nil && ![currentNode isKindOfClass:[NSDictionary class]])) {
    
                [self BGPrivateMethod_throwExceptionForInvalidSettingsPath];
            }
    
            NSString *key = obj;
            id nextNode = currentNode[key];
    
            if (nil == nextNode) {
                *stop = YES;
            } else {
                if (![nextNode isKindOfClass:[NSMutableDictionary class]])
                    valueForSettingsPath = nextNode;
            }
    
            currentNode = nextNode;
        }];
    
        return valueForSettingsPath;
    }
    
    - (BOOL)boolValueForSettingsPath:(NSString *)settingsPath
    {
        return [[self valueForSettingsPath:settingsPath] boolValue];
    }
    
    - (NSInteger)integerValueForSettingsPath:(NSString *)settingsPath
    {
        return [[self valueForSettingsPath:settingsPath] integerValue];
    }
    
    - (NSUInteger)unsignedIntegerValueForSettingsPath:(NSString *)settingsPath
    {
        return (NSUInteger) [[self valueForSettingsPath:settingsPath] integerValue];
    }
    
    - (CGFloat)floatValueForSettingsPath:(NSString *)settingsPath
    {
        return [[self valueForSettingsPath:settingsPath] floatValue];
    }
    
    - (NSString *)stringValueForSettingsPath:(NSString *)settingsPath
    {
        return (NSString *) [self valueForSettingsPath:settingsPath];
    }
    
    - (NSArray *)arrayValueForSettingsPath:(NSString *)settingsPath
    {
        return (NSArray *) [self valueForSettingsPath:settingsPath];
    }
    
    - (NSDictionary *)dictionaryValueForSettingsPath:(NSString *)settingsPath
    {
        return (NSDictionary *) [self valueForSettingsPath:settingsPath];
    }
    
    - (NSData *)dataValueForSettingsPath:(NSString *)settingsPath
    {
        return (NSData *) [self valueForSettingsPath:settingsPath];
    }
    
    
    - (NSString *)description
    {
        return [_settings description];
    }
    
    #pragma mark - Private methods
    
    - (void)BGPrivateMethod_saveSettings
    {
        [[NSUserDefaults standardUserDefaults]
                         setValue:_settings
                           forKey:kBGSettingManagerUserDefaultsStoreKeyForMainSettings];
        [[NSUserDefaults standardUserDefaults]
                         setValue:_defaultSettings
                           forKey:kBGSettingManagerUserDefaultsStoreKeyForDefaultSettings];
    
        [[NSUserDefaults standardUserDefaults] synchronize];
    }
    
    - (void)BGPrivateMethod_loadExistingSettings
    {
        id settings = [[NSUserDefaults standardUserDefaults]
                                       valueForKey:kBGSettingManagerUserDefaultsStoreKeyForMainSettings];
        id defaultSettings = [[NSUserDefaults standardUserDefaults]
                                              valueForKey:kBGSettingManagerUserDefaultsStoreKeyForDefaultSettings];
    
        _settings = (settings ? settings : [NSMutableDictionary new]);
        _defaultSettings = (defaultSettings ? defaultSettings : [NSMutableDictionary new]);
    }
    
    - (NSMutableDictionary *)BGPrivateMethod_deepMutableCopy:(NSDictionary *)settings
    {
        NSMutableDictionary *deepMutableCopy = [settings mutableCopy];
    
        [settings enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
            if ([obj isKindOfClass:[NSDictionary class]])
                deepMutableCopy[key] = [self BGPrivateMethod_deepMutableCopy:obj];
            else
                deepMutableCopy[key] = obj;
        }];
    
        return deepMutableCopy;
    }
    
    - (void)BGPrivateMethod_throwExceptionForInvalidSettingsPath
    {
        if (self.throwExceptionForUnknownPath)
            [NSException raise:@"Invalid settings path."
                        format:@"Some of your setting path components may intersect incorrectly or they don't exist."];
    }
    
    @end
    


    Использовать очень просто и, как я потом выяснил и понял, удобно:
    //    CODE -- begin
        BGSettingsManager *settingsManager = [BGSettingsManager shared];
    
        [settingsManager createDefaultSettingsFromDictionary:@{
                @"user": @{
                        @"info":@{
                                @"name": @"Andrew",
                                @"surname": @"Shmig",
                                @"age": @22
                        }
                }
        }];
    
        [settingsManager resetToDefaultSettings];
    
        [settingsManager setValue:@"+7 920 930 87 56"
                  forSettingsPath:@"user.info.contacts.phone"];
    
        NSLog(@"%@", settingsManager);
    
        [settingsManager clear];
    
        NSLog(@"%@", settingsManager);
    //    CODE - end
    


    В консоли получим такой вывод:

    2014-04-30 23:45:03.842 BGUtilityLibrary[13730:70b] {
        user =     {
            info =         {
                age = 22;
                contacts =             {
                    phone = "+7 920 930 87 56";
                };
                name = Andrew;
                surname = Shmig;
            };
        };
    }
    2014-04-30 23:45:03.847 BGUtilityLibrary[13730:70b] {
    }
    


    Переходим к игровому экрану. Этот экран стал для меня по-настоящему «кровавым»… дело в том, что в самом начале, при нажатии на кнопку «Играть», происходил переход на игровой экран и там, в viewDidLoad методе генерировалось и заполнялось поле (SKScene), но задержка была такой большой, что пришлось задаться вопросами:

    • Поможет ли предзагрузка спрайтов?
    • Поможет ли создание единой SKNode, добавление спрайтов в неё и лишь потом добавление на SKScene?


    На оба вопроса ответ «Нет». Первый вопрос вытекает из второго… вся проблема в том, что addChild: метод работает крайне медленно, именно поэтому надо стараться держать на сцене как можно меньше нод. ФПС кстати у меня на симуляторе больше 30 не поднимался, девайс же выдавал чистые 60.

    Методом научного тыка и вопросами на SO:
    1. SKSpriteNode takes too much time to be created from texture
    2. Strange thing happens with SKSpriteNode with transparent borders (не совсем по этому вопросу, но тоже очень интересный момент)

    Пришел к тому, что создал игровой экран в виде синглтона, который инициализируется при старте приложения, поле заполняется только травой (нижний слой генерируется лишь после того, как пользователь нажал на какую-то ячейку… это решение еще было принято и потому, что с первого же нажатия пользователь не должен попасть на мину).
    Все спрайты предзагружаются, далее — копируются, а не создаются заново, потому что процесс создания спрайта с уже загруженной в память текстуры оказался очень медленным и проигрывает простому копированию.
    Звуки тоже подгружаются при запуске приложения следующим образом:
    //    предзагрузка звуков в фоновом режиме для избежания затормаживания
        NSArray *audioResources = @[@"switchON.mp3",
                                    @"switchOFF.mp3",
                                    @"flagTapOn.mp3",
                                    @"grassTap.mp3",
                                    @"buttonTap.mp3",
                                    @"flagTapOff.mp3",
                                    @"explosion.wav"];
    
        for (NSString *audioName in audioResources) {
            [[BGResourcePreloader shared] preloadAudioResource:audioName];
        }
    


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

    В разделе о дизайне я упоминал, что работа с кастомными шрифтами еще то веселье — так и есть. Мало того, что название шрифта != названию файла со шрифтом, так еще без предзагрузки шрифта эта зараза неплохо тормозит (к сожалению все скрины из Instruments уже давно удалены, но проверить не составит труда).

    Поле у меня заполняется (генерируется) вот таким образом:
    - (void)generateFieldWithExcludedCellInCol:(NSUInteger)cellCol
                                           row:(NSUInteger)cellRow
    {
        BGLog();
    
        //        множество клеток
        NSMutableArray *cells = [NSMutableArray new];
    
    //        заполняем поле пустыми значениями
        _field = [NSMutableArray new];
    
        for (NSUInteger i = 0; i < self.cols; i++) {
            [_field addObject:[NSMutableArray new]];
    
            for (NSUInteger j = 0; j < self.rows; j++) {
                [_field[i] addObject:@(BGFieldEmpty)];
    
    //                добавляем ячейку в множество, если она не "запрещена"
                if (!(i == cellCol && j == cellRow))
                    [cells addObject:@(i * kBGPrime + j)];
            }
        }
    
    //        произвольно располагаем бомбы на поле
        sranddev();
    
        for (NSUInteger i = 0; i < self.bombs; i++) {
            NSUInteger index = arc4random() % [cells count];
    
            NSUInteger randomCell = [cells[index] unsignedIntegerValue];
            NSUInteger col = randomCell / kBGPrime;
            NSUInteger row = randomCell % kBGPrime;
    
            _field[col][row] = @(BGFieldBomb);
    
    //            удаляем использованную клетку
            [cells removeObjectAtIndex:index];
        }
    
    //        расставляем цифры
        _x = @[@0, @1, @1, @1, @0, @(-1), @(-1), @(-1)];
        _y = @[@(-1), @(-1), @0, @1, @1, @1, @0, @(-1)];
    
        for (NSUInteger i = 0; i < self.cols; i++) {
            for (NSUInteger j = 0; j < self.rows; j++) {
                NSInteger cellValue = [_field[i][j] integerValue];
                NSInteger count = 0;
    
                if (cellValue == BGFieldEmpty) {
                    for (NSUInteger k = 0; k < _x.count; k++) {
                        NSInteger newY = i + [_x[k] integerValue];
                        NSInteger newX = j + [_y[k] integerValue];
    
                        if (newX >= 0 && newY >= 0 && newX < self.rows && newY < self.cols) {
                            if ([_field[(NSUInteger) newY][(NSUInteger) newX] integerValue] == BGFieldBomb) {
                                count++;
                            }
                        }
                    }
    
                    _field[i][j] = @(count);
                }
            }
        }
    }
    


    В стороне остались детали работы с SKAction, которые на первый взгляд кажутся удобными, но в сапёре я столкнулся с необходимостью их комбинировать таким образом, чтобы сохранить читабельный код, но увы… это с большим трудом получилось.

    App Store


    Коротко и ясно:



    Конец


    Всем спасибо за внимание.

    Любые вопросы в комментарии и с удовольствием отвечу на них. Если что забыл в статье указать — пишите.

    В ближайшее время планирую открыть полностью проект и выложить на GitHub, но сперва будут реализованы задуманные вещи.
    Поделиться публикацией

    Комментарии 15

      +1
      В ближайшее время планирую открыть полностью проект и выложить на GitHub, но сперва будут реализованы задуманные вещи.

      А не могли бы заранее создать репозиторий, чтобы на него подписаться?
      +1
      Идея про открытость проекта нравится. Кстати, у картинок в оформлении поста есть параметры width=" ", height =" ", а обширный код можно в спойлеры прятать.
        0
        Ага, сейчас пошаманю с шириной.
        +1
        Раз отказались от 4 и всего, что ниже, значит рисовать надо только под ретину — отлично!

        5 от 4 и 4s отличается только размером экрана.

        Три вещи над которыми дизайнер дольше всего работал:
        Анимация взрыва бомбы

        Посмотрите на бесплатный Explosion Generator 3

        ширина текстовой надписи (UILabel) изменялась и менялось само расстояние между символами, что нас не устраивало

        Нужно было сразу или искать моноширный шрифт или делать его самому как bitmap font (что надо было делать с самого начала, если вы конечно считаете draw calls).

        Не обижайтесь, но сам подход «подгонка по пикселям GUI» не будет работать ни на одной платформе нормально. После выхода 6го айфона неумение работать с разными разрешениями вам аукнется. Я молчу про андроид.

        Удивительно что на такой простой сцене у вас возникли какие-то проблемы с производительностью. Вы использовали стандартные техники SpriteFrameCache и SpriteBatchNode?
          0
          Посмотрите на бесплатный Explosion Generator 3

          Большое спасибо, глянем.

          Не обижайтесь, но сам подход «подгонка по пикселям GUI» не будет работать ни на одной платформе нормально. После выхода 6го айфона неумение работать с разными разрешениями вам аукнется. Я молчу про андроид.

          Здесь я согласен с вами, но возможная потеря пропорций меня напрягала. Как с этим бороться?

          Удивительно что на такой простой сцене у вас возникли какие-то проблемы с производительностью. Вы использовали стандартные техники SpriteFrameCache и SpriteBatchNode?

          SpriteKit, а не Cocos2D.
            0
            SpriteKit, а не Cocos2D.

            Я думал они 1 в 1 передрали API кокоса. Значит я вам не помогу с этим, к сожалению.

            Здесь я согласен с вами, но возможная потеря пропорций меня напрягала. Как с этим бороться?

            Универсального рецепта нет, к сожалению. Для приложений вроде вашего, где вся сцена должна располагаться на одном экране (если я правильно понял), техники примерно могут быть следующими:
            — при поддержке 4+ айфонов, можно принимать размер экрана 4ки за всю активную игровую сцену (с клетками), а на 5+ девайсах добавлять арт, компенсирующий разницу в высоте. В вашем случае можно было сделать разные элементы управления: вашу текущую версию оставить для 5+, а для 4+ сделать более компактное меню (ваше вполне можно уменьшить в пару раз без потери функциональности).
            — при поддержке зоопарка андроидов, а так же 6+ айфонов, делать несколько наборов ассетов + скалирование. Для вашего случая за минимальный размер экрана можно было бы выбрать 4й айфон, условно говоря это был бы размер 1х. При загрузке игры расчитывать размер клетки (исходя из разрешения экрана и количества клеток по вертикали-горизонтали). Если размер больше 4го айфоновского, но меньше условного 1.5х, то брать ассеты размера 1.5х и скейлить до нужного размера. Если больше 1.5х, то скейлить из 2х и т.д. Все зависит от спектра поддерживаемых разрешений. По хорошему вы должны требовать от художника всего арта в нескольких размерах сразу, к примеру исходный, 2х и 4х для планшетов. В идеале вы должны следить, что бы ассеты были нарисованы под каждый размер (с уменьшением/увеличением детализации), а не просто отскейлеными в фотошопе, но это будет очень дорого, особенно учитывая анимации.
            — весь код должен работать с design resolution, а не напрямую с физическим разрешением экрана. Как дела обстоят в вашем фреймворке я не знаю.

            Да, скалирование не бесплатная операция, но я не знаком с sprite kit, поэтому не могу дать какие-то советы на этот счет.
              0
              для 4+ сделать более компактное меню (ваше вполне можно уменьшить в пару раз без потери функциональности).

              Хм, это вариант, надо посмотреть, что из этого получится. Хотя, есть подозрение, что выглядеть это будет криво.
              Вот, как выглядит игровой экран на 3.5:


              Снизу обрезаются 11 и 12 ряды ячеек и немного 10. При уменьшении размеров верхней панели никак не получится отобразить 2 ряда без ресайза, а ресайз опять таки скажется на пропорциях. Делать поле со скроллом — может и выход, но пока мне этот вариант не нравится.
          0
          позиционирование элементов и указание позиций

          Да уж. Позиционирования элементов в пикселях от края… неужели в фреймворке нет умных лэйаутов как в Qt, WPF.

          О поле на разных размерах экранов — Вам товарищ «murr» правильно сказал, игровое поле (клетки с минами и без) можно не масштабировать, а обнести их столбиками с оранжевой лентой для визуального отделения минного поля от не игрового пространства. А зная размер экрана и поля добавить по 4м сторонам заливку травой без разделения на клетки. Ваш игровой экран 3х5 нельзя осознать — где там минное поле, где безопасная зона.
            0
            Да уж. Позиционирования элементов в пикселях от края… неужели в фреймворке нет умных лэйаутов как в Qt, WPF.

            Проблема в сохранении пропорций, а не в автоматическом позиционировании элементов относительно друг друга.
            Возьмите те же спрайты на игровом поле, они у нас квадратные, если изменить поле (уменьшить по высоте), то при изменении scale тайлы станут прямоугольными, что нам не надо. Вот и возникает вопрос о сохранности и размеров поля и пропорций.

            можно не масштабировать, а обнести их столбиками с оранжевой лентой для визуального отделения минного поля от не игрового пространства. А зная размер экрана и поля добавить по 4м сторонам заливку травой без разделения на клетки.

            В это что-то есть, такое решение даст несколько положительных моментов в сочетании со скроллом — различные размеры поля.
            Но опять же, вы предлагаете решение для случая, когда размеры поля _меньше_ размеров экрана, а не наоборот.

            Ваш игровой экран 3х5 нельзя осознать — где там минное поле, где безопасная зона.

            Не совсем вас понял. Разъясните подробнее пожалуйста.
            0
            Самое интересное в пост не влезло, а именно: что было с игрой после одобрения эпплом? Скачало ли ее больше чем 10 человек, продвигали ли ее как-то и сели да, то как? Сделать игру нынче 10% дела, остальные 90% — сделать так, чтоб в нее хоть кто-то поиграл.
              0
              После публикации вот такой график (статистика за 30 апреля, 1 мая и 2 мая):


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

                Не забудьте выложить игру на гитхаб, как вы обещали ранее! Данные ранее ссылки на гитхаб дают 404.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое