Sapper: Royal Engineer

в 20:20, , рубрики: Без рубрики

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

В данном посте я расскажу «историю» разработки и публикации первой нашей игры: как рисовался дизайн, как разрабатывали, с какими трудностями столкнулись, почему 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 и всего, что ниже, значит рисовать надо только под ретину — отлично!

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

Sapper: Royal Engineer

Sapper: Royal Engineer

Дальше мы задавались примерно такими вопросами:

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

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

Sapper: Royal Engineer

Sapper: Royal Engineer

Sapper: Royal Engineer

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

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

Sapper: Royal Engineer Sapper: Royal Engineer

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

  • Фоновое изображение главного экрана
  • Фоновое изображение экрана настроек
  • Анимация взрыва бомбы

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

Sapper: Royal Engineer

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

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

Sapper: Royal Engineer

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

Звуки

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

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

Разработка

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

Sapper: Royal Engineer

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

Sapper: Royal Engineer

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

Начнем с главного экрана. Две кнопки, по тапу осуществляется переход на экран игры и в настройки. Барабанная дробь… по тапу еще проигрывается звук, а значит здесь либо 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

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

Sapper: Royal Engineer

Конец

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

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

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

Автор: AndrewShmig

Источник

Поделиться

* - обязательные к заполнению поля