Застосування 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, щоб, наприклад, була можливість зберегти поточний стан послідовності.
- GKRandomSource — базовий генератор випадкових чисел, від якого успадковуються всі наступні Random Source класи.
- GKARC4RandomSource — генератор випадкових чисел, який реалізує вже звичний в iOS алгоритм ARC4 (arc4random). Особливість також полягає в тому, що у цього джерела є метод dropValues(_:) , який допомагає відкинути певну кількість перших послідовностей, щоб було складніше передбачити ймовірне значення.
let arc4 = GKARC4RandomSource() // Мінімальна рекомендована кількість похилої значень послідовності arc4.dropValues(768) // Генерація випадкового числа від 0 до 10 arc4.nextInt(upperBound: 11)
- GKLinearCongruentialRandomSource — генератор чисел, що реалізує алгоритм лінійного конгруэнтного генератора , який швидше, але менш випадковий, ніж стандартний ARC4. Основна перевага його в тому, що цей алгоритм є в стандартних бібліотеках деяких мов програмування. Тому іноді його можна застосовувати для створення однакової послідовності випадкових чисел на різних платформах. Приміром, в Java цей алгоритм використовується в java.util.Random . Також його варто застосовувати в тому випадку, якщо ви дійсно робите десятки або сотні генерацій в секунду, інакше різниця в продуктивності буде практично непомітна.
let linearCongruential = GKLinearCongruentialRandomSource() // Генерація випадкового числа від 0 до 10 linearCongruential.nextInt(upperBound: 11)
- GKMersenneTwisterRandomSource — генератор випадкових чисел, що реалізує алгоритм вихор Мерсенна , розроблений японськими вченими, який є більш випадковим, але і менш продуктивним, ніж ARC4. Реалізований в стандартних бібліотеках: C++, Python, Ruby, PHP.
let mersenneTwister = GKMersenneTwisterRandomSource() mersenneTwister.nextInt(upperBound: 11)
Дуже зручно, що всі ці джерела мають однаковий інтерфейс і не потрібно щоразу вивчати специфіку використання кожного окремо.
За рахунок того, що всі ці класи успадковуються від GKRandomSource , який є суперкласом для всіх представлених алгоритмів, це дозволяє створювати відразу всі генератори незалежними один від одного і в той же час детермінованими. При цьому ми можемо легко виробляти реплікацію з збереженням послідовності кожного з алгоритмів.
Random Distribution
Ще однією важливою перевагою рандомізації через GameplayKit є можливість формувати Random Source разом з Random Distribution (методом випадкового розподілу).
Всього нам представлено 3 класу Random Distribution:
- GKRandomDistribution — розподіл, де рівномірна ймовірність генерації будь-якого числа в зазначеному діапазоні приблизно рівнозначна. Таким чином, виключається упередженість щодо будь-якого можливого результату. Що приємно, цей клас має зручний інтерфейс, щоб відразу ініціалізувати аналог 6-гранного кубика, або 20-гранного кубика, або навіть 100-гранного кубика.
// Це також можна зробити через 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()
- GKGaussianDistribution — генератор, який реалізує розподіл Гауса (нормальний розподіл ) з множинним вибірках. Якщо коротко, то такий алгоритм рандома дозволяє отримувати середні значення у вказаному інтервалі мінімального і максимального значення. Наприклад, у додатку потрібно користувачеві щодня видавати бонус за використання, і такий алгоритм підійде, щоб завжди надавати усереднене значення. Або в іграх, коли необхідно генерувати юніти, які майже завжди будуть з усередненими характеристиками.
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 відхилень.
- GKShuffledDistribution — генератор чисел, які рівномірно розподілені по безлічі вибірок, але де короткі послідовності схожих значень виключені. Таким чином, якщо у нас буде вказана генерація чисел від 1 до 5, то значення 5 випаде вдруге тільки після того, як всі інші числа від 1 до 4 точно так само випадуть по одному разу. Найчастіше таку реалізацію ми можемо зустріти в плей-листах сучасних плеєрів.
// Альтернативна ініціалізація діапазону чисел як у 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 класів:
- GKState — абстрактний клас, від якого ми наследуемся, щоб створити окремий об'єкт конкретного стану. Кожний такий клас визначає нове інше State-стан, в яке він можете перейти.
- GKStateMachine — сама State-машина, що містить у собі об'єкти станів, які успадковуються від GKState.
Створюємо свою 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