Застосування GameplayKit Randomization і State Machine в iOS-проектах

У попередній статті було описано, як застосовувати ігровий 2D-движок SpriteKit для швидкого створення простих анімацій в iOS. У новій статті я хочу поділитися, як використовувати GameplayKit в неігрових додатках.

GameplayKit — це набір інструментів, який Apple представляє для швидкого конструювання ігрових процесів і алгоритмів. Розглянемо інструменти, які застосовуються навіть в UIKit/Appkit-проектах.

Randomization

Так називається інструмент, що дозволяє застосовувати різні алгоритми рандома, які досить часто доводиться використовувати в іграх. Тут не буде обговорюватися генерація рандомних чисел для створення секретних ключів шифрування, так як навіть в самій документації у Apple зазначено, що ці сервіси рандомізації не є криптографічно стійкі, і для таких цілей рекомендується застосовувати зовсім інші інструменти .

Раніше найчастіше багато застосовували метод random() або arc4random(), побудований на ARC4-алгоритмі і генерує числа від 0 до 4294967295. Після виходу Swift 4.2 з'явилися нові методи для генерації рандома:

let randomInt = Int.random(in: 0..<10)
let randomDouble = Double.random(in: 5.71838...6.15249)
let randomBool = Bool.random()

Зазвичай цих інструментів достатньо, якщо нам потрібно просто згенерувати випадкове число, не замислюючись про наслідки. У таких випадках ви ніяк не зможете впливати на алгоритм рандомізації, послідовність і частоту випадання певних значень. А якщо спробувати впливати на цей процес, виробляючи генерацію кілька разів, щоб отримувати потрібний range значень, і ще це потрібно робити на кожний кадр, то продуктивність роботи може сильно постраждати. Така ситуація обумовлена тим, що в сучасних іграх часто відбувається одночасна генерація декількох випадкових чисел за один кадр і, таким чином, щоб підтримувати 60 кадрів в секунду, доведеться кілька десятків, а іноді й сотень разів в секунду ініціалізувати генерацію і обробку випадкових чисел.

Такий підхід має і проблеми у створенні послідовності однакових чисел у двох і більше користувачів, особливо якщо ці люди використовують різні платформи, код яких написаний на інших мовах програмування.

Саме для цього і застосовується Randomization з GameplayKit, дозволяючи зробити генерацію більш детермінованою .

Random Source

Власне, весь процес рандома складається з об'єкта-суперкласу GKRandomSource , який є джерелом рандомних чисел (Random Source), а також успадкування від протоколу GKRandom.

Сам протокол GKRandom представляє мінімальний інтерфейс для генерації випадкових чисел і складається всього з 4 методів:

let randomSource = GKRandomSource.sharedRandom()
// повертає випадкове значення Int32.min і Int32.max
// діапазон чисел від -2 147 483 648 до 2 147 483 647 
randomSource.nextInt()
// повертає випадкове значення Int між 0 і 9
randomSource.nextInt(upperBound: 10)
// повертає випадкове Float значення в діапазоні від 0.0 до 1.0
randomSource.nextUniform()
// повертає випадкове Bool
randomSource.nextBool()

GameplayKit пропонує один базовий і 3 альтернативних Random Source, які є детермінованими і можуть бути серіалізовать з використанням NSCoding, щоб, наприклад, була можливість зберегти поточний стан послідовності.

let arc4 = GKARC4RandomSource()
// Мінімальна рекомендована кількість похилої значень послідовності
arc4.dropValues(768)
// Генерація випадкового числа від 0 до 10
arc4.nextInt(upperBound: 11)
let linearCongruential = GKLinearCongruentialRandomSource()
// Генерація випадкового числа від 0 до 10
linearCongruential.nextInt(upperBound: 11)
let mersenneTwister = GKMersenneTwisterRandomSource()
mersenneTwister.nextInt(upperBound: 11)

Дуже зручно, що всі ці джерела мають однаковий інтерфейс і не потрібно щоразу вивчати специфіку використання кожного окремо.

За рахунок того, що всі ці класи успадковуються від GKRandomSource , який є суперкласом для всіх представлених алгоритмів, це дозволяє створювати відразу всі генератори незалежними один від одного і в той же час детермінованими. При цьому ми можемо легко виробляти реплікацію з збереженням послідовності кожного з алгоритмів.

Random Distribution

Ще однією важливою перевагою рандомізації через GameplayKit є можливість формувати Random Source разом з Random Distribution (методом випадкового розподілу).

Всього нам представлено 3 класу Random Distribution:

// Це також можна зробити через GKRandomDistribution.d6()
let ? = GKRandomDistribution(forDieWithSideCount: 6)
(1...10).forEach { _ in print(?.nextInt()) }
// 5 1 6 2 1 4 3 1 4 6

// Реалізація 20 гранного кубика
let d20 = GKRandomDistribution.d20()
d20.nextInt() 

// Реалізація 100 гранного кубика
let d100 = GKRandomDistribution(lowestValue: 1, highestValue: 100)
d100.nextInt()

Такий підхід дуже схожий на використання Int.random(in:) , але тут основна відмінність в тому, що можна заздалегідь ініціалізувати GKRandomDistribution, а потім заново використовувати його скільки завгодно, не ставлячи щоразу необхідний діапазон чисел. У поточному прикладі при реалізації розподілу буде використовуватися алгоритм ARC4 для генерації послідовності. Щоб перевизначити Random Source, досить просто ініціалізувати Random Distribution із зазначенням потрібного джерела.

let linearCongruential = GKLinearCongruentialRandomSource()
let ? = GKRandomDistribution(randomSource: linearCongruential,
 lowestValue: 1,
 highestValue: 6)
?.nextInt()
let random = GKRandomSource()
// У цьому прикладі, припустимо, що у нас 10 гранний кубик, щоб краще було видно розкид чисел
let ? = GKGaussianDistribution(randomSource: GKRandomSource(),
 lowestValue: 1,
 highestValue: 10)
(1...10).forEach { _ in print(?.nextInt()) } // Кидаємо кубик 10 разів
// 7 8 5 4 5 7 6 5 5 4

Також тут ми можемо впливати на рандомізацію, змінюючи очікуване середнє значення mean і крок інтервалу deviation . Візьмемо приклад, де середнє очікуване значення кубика буде 3, а крок інтервалу 1:

let ? = GKGaussianDistribution(randomSource: GKRandomSource(),
 mean: 3,
 deviation: 1)
(1...10).forEach { _ in print(?.nextInt()) }
// 2 3 3 3 2 2 3 4 2 2

У підсумку виходить, що близько 68% згенерованих чисел знаходяться в межах одного відхилення від значення mean, 95% — в межах 2 відхилень і майже 100% — у межах 3 відхилень.

// Альтернативна ініціалізація діапазону чисел як у 6 гранного кубика
let ? = GKShuffledDistribution.d6()
(1...7).forEach { _ in print(?.nextInt()) } // Кидаємо кубик 7 разів
// 4 5 3 1 2 6 4

Як можна бачити, тут значення межі з числом 4 повторюється тільки після того, як випадуть всі інші значення.

Що цікаво, метод shuffle() розподілу елементів в масиві, який був доданий в Swift тільки у версії 4.2, весь цей час був доступний в GameplayKit Randomization ще з iOS 9: arrayByShufflingObjects(in:) . Працюють вони, природно, на одному алгоритмі Фішера — Йетса . Але основна відмінність між ними лише в тому, що GameplayKit повертає новий масив, у той час як реалізація в Swift перемішує оригінальний.

Контроль послідовності рандома

Деякі показані мною приклади генерували значення з використанням конкретного алгоритму і вказаним способом розподілу, але при цьому можна впливати на послідовність цих випадкових чисел. У підсумку виходить, що цей рандом буде не таким вже і рандомным :)

Це може знадобитися, коли необхідно зробити однакову послідовність рандома на різних платформах або коли тестувальникам потрібно повторити певну послідовність. Всі показані мною GKRandomSource-класи (окрім базового) використовують властивість seed, яке доступне для зміни. Знаючи значення seed , ви можете дізнатися всю послідовність рандома.

let seed: UInt64 = 123
let randomSource1 = GKMersenneTwisterRandomSource(seed: seed)
let ?1 = GKRandomDistribution.d6()
(1...7).forEach { _ in print(?1.nextInt()) }
// 6 2 5 3 2 3 6

let randomSource2 = GKMersenneTwisterRandomSource(seed: seed)
let ?2 = GKRandomDistribution.d6()
(1...7).forEach { _ in print(?2.nextInt()) }
// 6 2 5 3 2 3 6

State Machine

У iOS вже давно була реалізована State Machine, яку цілком можна застосовувати, навіть у звичайних UIKit-проектах. І при цьому не потрібно використовувати Rx, NotificationCenter, OperationQueue або створювати величезні Enum's.

State Machine в GameplayKit має простий інтерфейс і складається всього з 2 класів:

Створюємо свою State Machine

В якості прикладу використання UIKit в додатку я покажу, як це можна застосувати, наприклад при завантаженні будь-якого файла на сервер.

Всього буде 3 стани: Uploading, Success, Failure.

Uploading State

final class UploadingDataState: GKState {

 private let viewController: UploadViewController

 init(_ viewController: UploadViewController) {
 self.viewController = viewController
}

 override func isValidNextState(_ stateClass: AnyClass) -> Bool {
 // Тут ми вказуємо, яким може бути наступний State
 return stateClass == SuccessfulState.self ||
 stateClass == FailureState.self
}

// Метод, який викликається коли State Machine успішно перейшла до цього стану
 override func didEnter(from previousState: GKState?) {
 // відображаємо анімацію завантаження поки перебуваємо в цьому стані
viewController.activityIndicator.startAnimating()

 // приклад виклику якийсь реалізації API запиту 
 API.fetchData { result in
 switch result {
 case .success:
stateMachine?.enter(SuccessfulState.self)
 case .failure:
stateMachine?.enter(FailureState.self)
}
}
}

// Метод, який викликається коли State Machine переходить до іншого станом
 override func willExit(to nextState: GKState) {
 // прибираємо анімацію завантаження коли покидаємо стан
viewController.activityIndicator.stopAnimating()
}
}

Successful and Failure State

final class SuccessfulState: GKState {

 private let viewController: UploadViewController

 init(_ viewController: UploadViewController) {
 self.viewController = viewController
}

 override func isValidNextState(_ stateClass: AnyClass) -> Bool {
 return stateClass == UploadingDataState.self
}

 override func didEnter(from previousState: GKState?) {
// Які дії необхідно зробити коли успішно отримали дані
}

 override func willExit(to nextState: GKState) {
// Дії коли закінчилася дія цього стану
}
}

final class FailureState: GKState {

 private let viewController: UploadViewController

 init(_ viewController: UploadViewController) {
 self.viewController = viewController
}

 override func isValidNextState(_ stateClass: AnyClass) -> Bool {
 return stateClass == UploadingDataState.self
}

 override func didEnter(from previousState: GKState?) {
// Які дії необхідно зробити коли не вдалося отримати дані
}

 override func willExit(to nextState: GKState) {
// Дії коли закінчилася дія цього стану
}
}

В результаті вийшла така проста схема:

Щоб запустити всі ці стани досить ініціалізувати GKStateMachine і передати туди всі створені State-класи.

final class UploadViewController: UIViewController {

 @IBOutlet private(set) var activityIndicator: UIActivityIndicatorView!

 override func viewDidLoad() {
super.viewDidLoad()

 let uploadingDataState = UploadingDataState(self)
 let successfulState = SuccessfulState(self)
 let failureState = FailureState(self)
 let stateMachine = GKStateMachine(states:
 [uploadingDataState, successfulState, failureState])
 // Відразу запускаємо Uploading State
stateMachine.enter(UploadingDataState.self)
}
}

Після запуску в UploadingDataState буде викликаний метод didEnter(from:) , активований activityIndicator і відправлений запит на сервер. Залежно від відповіді з сервера буде викликаний перехід до наступного стану, де ми вже зможемо реалізувати якусь іншу логіку. Приміром, можна буде легко написати реалізацію, щоб зі стану Failure ми повернулися в Uploading і повторили операцію. Також можна створити ще більше станів, які могли б робити інші операції перед успішної завантаженням або після, наприклад закешувати її у файловій системі або зробити заздалегідь компресію, перш ніж відправити на сервер. Таким чином, це може бути альтернативним варіантом, щоб створити хорошу послідовність дій, які будуть існувати як окремі класи, і тим самим уникнути Callback Hell'a .

Подивитися приклад, пропонований Apple по застосуванню GKStateMachine у вигляді гри, можна в архіві: Dispenser .

Висновок

У самого GameplayKit близько десятка інструментів, які допомагають працювати з SpriteKit. Але тільки деякі з них можуть стати в нагоді при розробці на UIKit. Тут я розглянув ті, які використовую найчастіше в розробці неігрових програм. Звичайно, велика частина статті була присвячена системі рандома, тому що з нею мені найчастіше доводиться мати справу. Але додатково це хороший інструмент, який дозволяє робити примітивно просту рутину з дуже легким для читання кодом і мінімальним інтерфейсом, без використання величезної кількості математичних формул і магічних чисел у своїх власних рандом-методи.

Якщо ж вам цікаво, як можна задіяти більшу частину інструментів GameplayKit, то рекомендую подивитися WWDC 2015 session 609 , Deeper into GameplayKit with DemoBots.

Вихідний код проекту, показаного на WWDC, ви може взяти в Documentation Archive або у мене в репозиторії , де код повністю сконвертирован до Swift 5.

Опубліковано: 08/11/19 @ 11:00
Розділ Різне

Рекомендуємо:

DOU Hobby: кікбоксинг – ефектне поєднання боксу і східних бойових мистецтв
Набір на 4 потік мого курсу SEO Шаолінь
«Живий» прогноз погоди, або Як використати генеративне мистецтво у вебі
Рейтинг ІТ-роботодавців 2019: опитування
BA дайджест #5: архітектура підприємства, формальні методи валідації UI