📜 ⬆️ ⬇️

Sapper: Royal Engineer

Habrarazrabotchiki, welcome!

In this post, I will tell you the “history” of the development and publication of our first game: how the design was drawn, how it was developed, what difficulties it encountered, why StackOverflow is better than the Apple Dev Forums, etc.
The game was made in order to form the mechanisms of interaction with the designer, to further accelerate the development of more complex games, so judge strictly (as much as possible).

Pictures to attract attention:
imageimage
')
imageimage



How did it all start?


It so happened that the new job, for certain reasons, had to develop games for machine guns. I worked with a talented designer who very much wanted to develop other games (for mobile platforms, for PC, XBox, etc.). Here we are with him and decided in parallel to develop something interesting, but at the same time, not too complicated, so that our development does not take 4-5-6 months. Neither I nor he were ready for such a long jump.

The sapper was not the idea for which we wanted to start at the very beginning.

This is what we wanted to tackle, but we are very glad that our forces correctly estimated in time:

Screenshots
image

image

image

image


First of all, I didn’t want to take a too complex game because I couldn’t yet appreciate the capabilities of SpriteKit and really didn’t want to be in the middle of development and understand that I could only implement a feature through a stump-deck.

On StackOverflow, I saw that Cocos2D was very actively supported by the author himself (LearnCocos2D), but I really wanted to try SpriteKit after the Apple presentation and the Adventure game. I was grieved by the fact that particle visualization is built into XCode, but I want to open XCode less often.

Instruments


At the initial stages a bunch of Xcode + AppCode, Photoshop.
Then only AppCode and Photoshop.

About SpriteKit can be viewed here or here .

Design (retina, non retina, iPhone 4 and 5)


I immediately was in favor of the fact that we did not support 4 iPhones and did not steam with a bunch of images, which we already had enough because of the peculiarities of the application localization. Once abandoned 4, and all that is lower, it is necessary to draw only under the retina - great!

Here, for example, looked like our first option:

Screenshots





Then we asked about such questions:


The following versions already looked like this:

Screenshots







Let the designer have the will, but control. The fact is that the font that he used for the time spent and the number of bombs on the field is not in the standard list, it means that you need to search the Internet. But this is still nothing, the fact is that we need to find the __font font, taking into account that behind the inscription there is a background image with 4 gray numbers with certain distances between them, and in most cases we were faced with the fact that when changing the inscription with " 111 "to" 888 "the width of the text label (UILabel) changed and changed the distance between the characters themselves, which did not suit us ... however, the required font was found, thank God, otherwise I would have to make 10 images, position them and update the counter accordingly. It would seem that a simple font, but alas, not everything is so simple (in the “Development” section I will tell you what else was interesting with this font).

There were no problems with sprites. Most of all we enjoyed this switch:



Three things that the designer worked the longest:


The animation of a bomb explosion contains about 40 frames (in the screenshot below, two types of explosions).



We faced the designer with one more problem - the positioning of elements and the indication of positions. For him, this is completely irrelevant, a pixel to the left, a pixel to the right - it does not matter ... he draws everything without rulers, such a creative person :)
This option did not suit me at all, because I somehow need to position it, which means we need at least some coordinates / dimensions.

It turned out something like this:



Convenient, but something is wrong here, I do not leave such a feeling.

Sounds



I also had to deal with the sounds of the designer. We found a suitable site www.freesound.org and used some sounds (it didn’t work at all without cutting - filtering):



Development



It all started with storyboards:



It ended:



Designing, designing, designing ... garbage is complete. You create a skeleton, and then you start winding up the functionality, logic, graphics, etc. From the beginning of the development to the end, my project structure has not changed, but the interaction schemes and logic of the controllers work many times.

Let's start with the main screen. Two buttons, tapu takes you to the game screen and settings. Drum roll ... a sound is still played on tapu, which means either SystemSound or AVAudioPlayer (or something else), which means that preloading is needed, which means another class is needed that would be responsible for preloading all sounds and playing them. And so it happened - 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 


The implementation is as follows:
 // // 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 


On the main screen, nothing more interesting.

Go to the settings screen.

We immediately have a UISegmentedControl (similar) and a switch (UIButton).
Before writing a bicycle with my own UISegmentedControl, I very carefully looked at StackOverflow and realized that it was better not to inherit, but to write a bicycle ... nothing complicated, but there are some features (the switch mechanism is such that even running fingers through it , the active option changes and depends not only on where you raised your finger, but also on where your finger is now located).

The main processing of a switch state change is as follows:

 #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)]; } 


There are no questions.

Our favorite element is the switch. At first, it worked like a normal button, but I was constantly enraged because it didn’t feel like it, and I want to feel that it is real and works the way it is.

As a result, I received the following code to switch between states:
 #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; } } 


With each change of state, we save the new value of the settings in the settings manager. The settings manager (which is in the application being released) turned out shitty, then I wrote a new one, but haven’t replaced it yet.

Here is the source code for the new settings manager:
 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 


Part with the implementation:
 // // 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 


It is very simple to use and, as I later found out and understood, it is convenient:
 // 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 


In the console we get the following output:

 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] { } 


Go to the game screen. This screen became really “bloody” for me ... the fact is that at the very beginning, when you clicked on the Play button, a transition to the game screen took place and there, the field (SKScene) was generated and filled in the viewDidLoad method, but the delay was so big that I had to ask questions:



For both questions, the answer is "No." The first question arises from the second ... the whole problem is that the addChild: method works extremely slowly, which is why you should try to keep as few nodes on the stage as possible. By the way, the FPS on my simulator did not raise more than 30, the device produced a clean 60.

Method of scientific poking and questions on SO:
1. SKSpriteNode takes too much time to be created from texture
2. Strange thing happens with SKSpriteNode with transparent borders (not quite on this issue, but also a very interesting point)

He came to the fact that he created the game screen in the form of a singleton, which is initialized when the application starts, the field is filled only with grass (the bottom layer is generated only after the user has clicked on a cell ... this decision was also made because same pressing the user should not get on the mine).
All the sprites are preloaded, then they are copied, and not re-created, because the process of creating a sprite from the texture already loaded into memory turned out to be very slow and loses to simple copying.
Sounds are also loaded when the application is launched as follows:
 //         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]; } 


After each change of the settings, the field is updated (regenerated and filled with sprites) so that there is no delay during the quick transition.

In the section on design, I mentioned that working with custom fonts is still fun - it is. Not only is the font name! = The name of the font file, this infection also slows down well without preloading the font (unfortunately all the screens from the Instruments have been removed for a long time, but it will not be difficult to verify).

My field is filled (generated) like this:
 - (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); } } } } 


Aside, there are details of working with SKAction, which at first glance seem convenient, but in the sapper I was faced with the need to combine them in such a way as to preserve the readable code, but alas ... it worked out with great difficulty.

App Store


Briefly and clearly:



the end


Thank you all for your attention.

Any questions in the comments and happy to answer them. If you forgot to specify in the article - write.

In the near future I plan to open the project completely and put it on GitHub, but first the things planned will be implemented.

Source: https://habr.com/ru/post/221519/


All Articles