Задание 3 Stanford CS 193P Spring 2020. Игра Set.

Решение обязательных и дополнительных пунктов.

Содержание

Цель этого задания состоит в том, чтобы дать вам возможность создать свое первое приложение полностью с «нуля» и самостоятельно. Оно похоже на первые два Задания, которое помогло обрести вам уверенность, но и достаточно отличающееся, чтобы дать вам полную свободу для накопления опыта!

Так как цель этого Задания — создать приложение с «нуля», то не начинайте с кода Задания 2, начинайте с New → Project в Xcode. .

Текст Домашнего Задания 3 на английском языке доступен на  сайте Stanford CS193P (Lecture 6 Assignment 3). На русском языке вы можете посмотреть и скачать Задание 3 здесь.

Вы, конечно, захотите освежить в памяти правила игры Set.

Мое решение Задания 3 находятся на Github  для iOS 14 и Xcode 12.

Пункты 1, 2 и 6 обязательные

1. Реализуйте игру Set в версии соло (для одного игрока).

2. Когда ваша игра запускается в первый раз, карты на короткое время не должны отображаться, но как только они появляются, необходимо немедленно сдать 12 карт, заставляя их «прилетать» из случайных мест за пределами экрана.

6. Пользователи должны иметь возможность выбрать до 3 карт, прикоснувшись к ним, чтобы попытаться создать Set (т. е. 3 совпадающие карты (matching) в соответствии с правилами игры Set). Пользователю должно быть ясно видно, какие карты уже были выбраны.

Колода карт для игры Set состоит из 81 карты, на каждой из которых изображены один, два или три одинаковых символа (ромба, овала или волны) одного и того же цвета (красного, зелёного или фиолетового) и одной и той же текстуры (закрашенные, заштрихованные или только контур). Одинаковых карт в колоде нет.

В этой игре есть понятие сета Set. Сет Set состоит из трёх карт, которые удовлетворяют всем условиям:

  • все карты имеют то же количество символов или же 3 различных значения;
  • все карты имеют тот же символ или же 3 различных символа;
  • все карты имеют ту же текстуру или же 3 различных варианта текстуры;
  • все карты имеют тот же цвет или же 3 различных цвета.

Один из вариантов сета Set представлен на рисунке выше.

Ход ИГРЫ

Из тщательно перетасованной колоды на игральный стол выкладывает 12 карт лицом вверх.

Как только игрок увидел сет Set среди выложенных карт, он говорит «Сет!». Если сет Set найден верно, нашедший его игрок забирает эти три карты себе и кладёт их на стол рубашкой вверх. Лежащие на столе карты дополняются тремя новыми картами из колоды. Игра тут же продолжается.
Если среди 12 карт не удается найти сет Set, а это может случиться с вероятностью 3%, то на игральный стол выкладывается ещё 3 карты и карт на столе становится 15. После того, как сет будет найден и три карты забраны игроком, на столе останется 12 карт, и игра будет продолжаться как обычно.
Если же и среди 15 карт, не удаётся найти сет Set, выкладывает из колоды ещё 3 карты. Если опять не удаётся найти сет, то снова выкладывает из колоды 3 карты, после чего столе будет 21 карта. Среди 21 карты сет есть всегда!

План создания приложения.

Нам нужно решить две задачи:

  • Создать Модели для карты и колоды карт игры Set, а также обеспечить визуальное представление карты Set.
  • Создать Модель логики игры Set, когда карты выбираются с помощью функции choose, и становятся выбранными isSelected, совпавшими isMatched или не совпавшими isNotMatched. Кроме того, необходимо обеспечить визуальное представление в SwiftUI хода игры, когда карты сдаются, карты уходят с игрального стола при обнаружении сета Set и заменяются на новые из колоды карт, сдаются дополнительные карты из колоды и т.д..

Понятно, что игра Set — это своеобразная игра «на совпадение» специальных карт, для который особо оговорено понятие «совпадение». Поэтому, конечно, мы воспользуемся той архитектурой приложения, которая была предложена профессором для игры Memorize «на совпадение» карт с эмоджи в демонстрационном примере Лекции 4.

Нам придется её немного усовершенствовать, потому что в игре Memorize участвуют 2 карты вместо 3-х карт в игре Set, в игре Memorize требуется простое совпадение 2-х карт,  а в игре Set требуется подборка 3-х карт, удовлетворяющих определенным правилам, в игре  Memorize участвует фиксированное число пар одинаковых карт, а в игре Set — целая колода карт с возможностью замены. 

Тем не менее, как нам и рекомендовано, мы начнем создание нового приложения с File -> New -> Project:

Создаем приложение для iOS:

Называем приложение SetGame:

Размещаем его в подходящем месте и получаем проект:

Для того, чтобы вы понимали о каких специальных картах идет речь, проще всего начать с Модели карты и колоды карт для игры Set.

Модель карты SetCard.

Добавим в наш проект File-> New ->File новый файл Swift file с именем SetCard.swift и разместим там Модель карты игры Set, которая представляется структурой struct SetCard. Основными свойствами этой структуры являются число символов на карте number, цвет символов color, сам символ shape и текстура (или заполнение) символа fill .

Все свойства в нашей карте SetCard имеют ТИП enum Variant, что представляет собой перечисление с вариантами v1,v2, v3, имеющими целые значения Int для rawValue. Это очень обобщенное представление карты для игры Set, в нашей Модели не указывается, какие конкретно задействованы символы, цвета и формы заполнения символов. Это, как мы увидим в последствии, позволит нам не встраивать «ориентированные на отображение» вещи, такие как цвета или даже имена символов (ромб, овал, прямоугольник) и виды заполнения символов (пустой, заштрихованный, закрашенный) в нашу Модель карты SetCard. Наша SetCard дает некоторое представление о структуре карты и совсем не говорит о том, как эта карта будет представлена пользователю.

Добавим еще один файл в наш проект с именем SetCardDeck.swift для колоды карт, которая представлена довольно простой структурой struct SetCardDeck с основным свойством в виде массива карт cards ТИПа SetCard:

С помощью метода draw( ) мы можем вынуть из колоды любую случайную карту SetCard.

View для карты SetCard.

Давайте попробуем нарисовать типичную Set карту с символами в виде ромба Diamond, овала Capsule и «загогулины»  Squiggle и вариантами заполнения символов в виде: пустой, заштрихованный и закрашенный.

Сначала займемся созданием нужных нам геометрических фигур Shape, которые рисовать в SwiftUI очень просто. Shape — это протокол.  Он наследует от View, так что все Shapes являются Views, и вы всегда можете разместить Shape в ZStack или где-то ещё. Shape требует от вас реализовать всего одну функцию func path:

func path (in rect: CGRect) -> Path {

   return Path
}

В этой функции вы создаете и возвращаете Path, которая рисует всё, что вы хотите. Path имеет огромное количество функций для поддержки рисования (см. документацию). Он может добавлять линии, дуги, кривые Безье и т.д., чтобы из всего этого создать свою геометрическую фигуру.

Ромб Diamond. Пункт 14 обязательный

14. Вы должны создать свою собственную структуру Shape для ромба (diamond).

Добавим в проект еще один Swift file с именем Diamond.swift и получим ромб Diamond с помощью обыкновенных линий:

Вот как выглядит этот ромб Diamond при различных вариантах заполнения (пустой, закрашенный и затененный):

«Загогулина» Squiggle. Пункт 2 дополнительный

Добавим в проект еще один Swift file с именем Squiggle.swift и получим «загогулину» Squiggle на этот раз немного более сложным способом, привлекая к рисованию кривые Безье. «Загогулину» Squiggle рисуем как комбинацию 3-х кубических кривых Безье и копии этой же кривой, модифицированной аффинными преобразованиями поворота (rotatedBy) на 180º и смещения (translatedBy) вниз:

Вот как выглядит код для нашей «Загогулины» Squiggle :

Вот как выглядит «Загогулины» Squiggle при различных вариантах заполнения (пустой, закрашенный и затененный):

Кстати, обратите внимание, что закрашенная «Загогулина» Squiggle ( fill( ) ) выглядит немного меньше остальных, для которых используется обводка ( stroke() ), так как закрашивание происходит только внутри геометрической фигуры, а обводка снаружи. Для закрашивания, по-видимому, стоит использовать сочетание закрашивания и обводки, но об этом немного позже.

Для овала используем встроенную геометрическую фигуры Capsule.

Для геометрической фигуры «Овал» мы будем использовать встроенную Shape с именем Capsule.

Штриховка геометрической фигуры Shape.

Еще мы должны научиться «штриховать» геометрические фигуры Shape. Для этого создадим «заштрихованный» прямоугольник StripedRect и научимся использовать его для штриховки геометрических фигур Shape:

Вот как просто можно использовать StripedRect для штриховки геометрических фигур Shape (на рисунке ниже — это ромб Diamond):

Для простоты использования штриховки, корректного закрашивания с обводкой. о котором мы говорили чуть выше,  и затушёвывания полупрозрачным цветом геометрических фигур Shape создадим расширение extension для Shape (находится в файле Shape+Extensions.swift):

Теперь штриховку, корректное закрашивание или затушёвывание геометрических фигур Shape мы будем выполнять одной строкой:

 VStack {
            Squiggle().blur(5)//stroke(lineWidth: 5)
            Squiggle().fillAndBorder(5)
            Squiggle().stripe(5)
        }.padding()

И мы увидим равные «Загогулины»:

У нас все готово для рисования геометрических фигур на карте SetCard:

Приступаем к рисованию самой карты SetCard. Добавляем в наш проект SwiftUI file с именем SetCardView.swift.

View для SetCardSetCardView.

Движущей силой для получения View карты SetCard является сама карта card и массив цветов colorsShapes символов, расположенных на карте, а также геометрические фигуры Diamond(), Capsule() и Squiggle() и варианты их заполнения: обводка — stroke(), закрашивание с обводкой — fillBorder () и штриховка — stripe().

C помощью rawValue для свойства card.number мы узнаем, сколько символов мы должны разместить в VStack, c помощью rawValue для свойства card.color мы узнаем, какого цвета должны быть эти символы, с помощью свойства card.shape мы узнаем, какие геометрические фигуры (или символы, как мы будем называть их в дальнейшем) мы сможем поместить на карте и, наконец, с помощью свойства card.fill мы узнаем, как заполнять наши символы — обводить, закрашивать или заштриховывать. Все очень просто.

Теперь мы можем визуализировать любую из 81 карты игры Set:

Итак, первый пункт нашего плана по созданию приложения для игры Set, который заключался в том, чтобы создать карту игры Set и представить ее визуально, выполнен, приступает ко второму пункту — созданию Модели логики игры Set и ее визуального отображения.

Модель игры Set.

Когда мы говорим о Модели карточной игры Set, то ясно, что она должна иметь карты на игровом столе, представленные переменной var cards, ТИП которой — массив Array элементов Card, колоду карт deck, которая также является массивом Array элементов Card, число сданных в начале игры карт numberOfCardsStart, число карт numberOfCardsToMatch, которые могут совпадать, и метод выбора карты choose (card:Card), который запускает всю игру Set.
Вот простейшая схема Модели игры Set:

Для логики игры Set карта Card носит ещё более абстрактный характер, чем наша SetCard, о которой мы говорили выше и для которой создали визуальный образ SetCardView.

Мы создаем UI независимую логику игры Set, так что нам действительно НЕ ВАЖНО, что находится на картах Card. Свойство content имеет “Не важно, какой” ТИП, и я назову его CardContent. Меня тут же просят вверху в угловых скобках < > СООБЩИТЬ МИРУ, что CardContent — это Generic ТИП:

Если в дальнейшем вы реально хотите использовать игру SetGame, то вам нужно будет точно сказать, какой реальный ТИП заменит CardContent. Как только мы начнем использовать эту Модель в нашей игре SetGame не с абстрактной картой Card, а с реальной картой SetCard, представленной выше, мы должны написать SetGame<SetCard>.

Это действительно удивительно простой пример того, как можно иметь дело с “Не важно, какими” (Generic) ТИПами. На самом деле так и есть — SetGame не беспокоится о том, что реально находится на картах cards.

Для логики игры Set почти неважно содержимое карты content и тем более, как выглядит карта (визуализация карты важна только для игрока), но для нее важно давать возможность игроку выбирать карты, то есть делать их «выбранными» isSelected и если таких «выбранных» карт накопится 3, то уметь оценивать их на предмет того, составляют они сет Set или нет. Если карты составляют сет Set, то нужно уметь пометить их как «совпавшие» isMatched и на следующем шаге игры удалить из игры.

Таким образом, содержимое карты content всё таки имеет не совсем “Не важно, какой” ТИП CardContent, этот ТИП должен иметь возможность определять Set по содержимому content некоторого количества карт ( в игре Set по 3-м картам). Такая функциональность карт Card игры Set закладывается с помощью  механизма Constraints and Gains (Ограничений и Выгод), именно так называет Пол Хэгерти принцип, положенный в основу Протоколо-Ориентированного Программирования (POP). Для обеспечения такой функциональности мы изобретаем протокол Matchable, который должен содержать static функцию func match (cards:[SetCard]) -> Bool, позволяющую определить, составляют ли карты cards сет Set

И устанавливаем «Ограничение» на “Не важно, какой” ТИП CardContent в виде реализации протокола Matchable:

Это «Ограничение», а «Выгода» заключается в том, что теперь мы можем использовать в нашей Generic игре SetGame функцию match (cards:[SetCard]) -> Bool, позволяющую установить наличие сета Set, и на этом построить всю логику игры.

При реальном использовании игры SetGame<CardContent> нам нужно будет точно сказать, какой реальный ТИП заменит CardContent. и этот ТИП должен обеспечить реализацию протокола Matchable.

Продолжаем исследовать свойства встроенной в игру SetGame структуры Card.

Для того, чтобы в дальнейшем мы могли использовать карты cards в ForEach на View нашей игры Set, карта Card должна быть Identifiable:

Пока у нас нет ни колоды карт deck, ни карт на игровом столе cards. Давайте создадим инициализатор init, который поможет нам «закачать» колоду карт deck в игру SetGame. Для этого мы должны задать число карт на игровом столе numberOfCardsStart при старте игры, количество карт в колоде numberOfCardsInDeck и генератор содержимого карты cardContentFaсtory:

По мере того, как карты Card наполняют нашу колоду deck, их свойству id присваивается значение индекса карты в колоде i, делающее карту Card Identifiable. Затем колода карт deck перемешивается.

Нам еще понадобится функция сдачи карт deal, аргументом которой является количество сдаваемых карт numberOfCards, которое по умолчанию равно nil, в этом случае количество сдаваемых карт будет равно числу карт на игровом столе numberOfCardsStart при старте игры:

Эта же функция deal будет использоваться при сдачи дополнительных 3-х карт, если на игровом столе отсутствует сет Set и в колоде карт есть еще карты.

Основное действие, которое пользователь осуществляет в игре Set, это выбор карты с помощью функции choose (card:Card). Если выбранная карта card до этого момента не была выбрана (isSelected = false) и не является «совпавшей» (isMatched = false), то дальнейшие события в игре Set будут происходить в зависимости от того, сколько карт уже выбрано пользователем на игровом столе, поэтому нам понадобится вычисляемая переменная var selectedIndices :

В методе choose мы находим индекс chooseIndex выбранной пользователем карты card, убеждаемся, что она не была выбрана (isSelected = false) ранее и не входит в сет Set (isMatched = false) и исследуем число уже выбранных к этому моменту карт selectedIndices. Если их уже две, то мы метим «выбранной» (isSelected = true) карту пользователя, и «выбранных» карт становится 3. Теперь мы должны руководствоваться обязательным пунктом 7 Задания 3:

7. После того, как были выбраны 3 карты (selected), вы должны показать, совпадают ли эти 3 карты (match) или нет (mismatch). Вы можете показать это как хотите (цветом, границами, фоном, анимацией, чем угодно). Каждый раз, когда выбраны 3 карты (selected), пользователю должно быть ясно, совпадают (match) они или нет (mismatch) (и карты, входящие в не совпавшее трио, должны выглядеть иначе, чем карты, когда в случае выбора 1 или 2 карт).

Когда выбраны 3 карты, мы вправе выполнить проверку «выбранных» карт на наличие сета Set с помощью static функции match протокола Matchable. Если это сет Set, то мы метим выбранные карты как «совпавшие» ( isMatched = true), а если нет — то метим как «не совпавшие» (isNotMatched = true), Мы вынуждены добавить свойство isNotMatched к карте Card, так как нас просят в Задании 3 четко показать, что эти 3 карты «не совпали»:

Продолжаем разрабатывать метод choose для случая, когда число уже выбранных карт равно 0 или 1 или 3, и здесь мы должны руководствоваться обязательными пунктами 9 и 10 Задания 3:

9. Если вы касаетесь любой карты,  когда уже выбраны 3 совпадающие (matching) карты, образующие Set, тогда …

    1. согласно правилам игры Set, замените эти 3 совпавших (matching) Set карты новыми из колоды 
    2. совпадающие (matching) карты должны улетать (с анимацией) в случайные места за пределами экрана
    3. заменяющие карты должны прилететь (с анимацией) из других случайных мест за пределами экрана (или из «колоды» где-нибудь на экране, см. Дополнительные пункты)
    4. если колода пуста, то место, освобожденное совпадающими картами (которые не могут быть заменены), должно быть доступно для оставшихся карт (т.е. они, вероятно, станут больше)
    5. если карта, которую вы коснулись, не была частью совпадающих (matching) карт, образующих Set, выберите эту карту

10. Когда выбрана новая карта и есть уже 3 выбранных (selected) и не совпавших карты, сделайте эти 3 не совпавших карты не выбранными (deselected), а новую карту выбранной  (selected)  (независимо от того, была ли она частью не совпавшего трио карт).

Если количество выбранных карт selectedIndices.count равно 0 или 1, то мы просто метим карту пользователя card как «выбранную» (isSelected = true). Если же у нас 3 выбранных карты, то мы меняем / удаляем карты с помощью метода changeCards(), если они «совпали», и делаем все карты «не выбранными» (isSelected = false) и не «не совпавшими» (isNotMatched = false), кроме карты пользователя card, с помощью метода onlySelectedCard.

Рассмотрим метод changeCards(), который анализирует «совпавшие» карты с помощью вычисляемой переменной matchedIndices:

В методе changeCards мы работаем только с индексами «совпавших» карт matchedIndices. Если в колоде есть карты и число карт на игровом столе равно количеству карт при старте игры, мы производим замену карт на игровом столе, если нет — удаляем совпавшие карты с игрового стола.

ViewModel для конкретной игры SetGame<SetCard>

Таким образом, мы получили первый рабочий вариант игры SetGame. Давайте используем её для игры Set с картой SetCard, о которой мы говорили выше, в нашей очень простой реактивной ViewModel.

Для этого добавим в наш проект Swift файл SetCardGame.swift и разместим там следующий код :

При реальном использовании игры SetGame<CardContent> мы точно указали, какой реальный ТИП заменяет «Не важно, какой» ТИП CardContent. Мы будем использовать Модель игры SetGame с картой SetCard, о которой мы говорили в выше и создавали ее визуальное отображение. В этом случае мы должны не только написать SetGame<SetCard>, но и обеспечить реализацию протокола Matchable структурой struct SetCard.

Это практически точно такая же ViewModel, о которой Пол Хэгерти говорил на Лекции 3. С помощью «Обертки» @Published мы создали Модель model игры SetGame<SetCard> с картами SetCard, с колодой карт deck и начальным числом карт на игровом столе numberOfCardsStart. Мы предоставим View, которое будет потребителем этой ViewModel, доступ к картам на игровом столе cards и возможность осуществлять выбор карты с помощью метода choose(card:), а также позволим сдавать карты в начале игры с помощью метода deal().

Теперь, для того, чтобы наша карта SetCard могла принимать участие в игре Set «, она должна быть Matchable, а для этого она должна реализовать static функцию func match (cards:[SetCard]) -> Bool.

В нашей структуре struct SetCard функцию match реализовать очень легко.

Сет Set состоит из трёх карт SetCard , которые удовлетворяют всем условиям:

  • все карты имеют то же количество символов number или же 3 различных значения;
  • все карты имеют тот же символ shape или же 3 различных символа;
  • все карты имеют ту же текстуру fill или же 3 различных варианта текстуры;
  • все карты имеют тот же цвет color или же 3 различных цвета.

Для каждого из 4-х свойств (number, color, shape и fill) мы вычисляем сумму sum значений awValuer для всех 3-х карт. Если эта сумма делится нацело на 3, то есть  она равна  3, 6 или 9, то соответствующее свойство для всех 3-х карт либо одинаковые (3, 6 или 9), либо разные (6). Если для всех 4-х свойств (number, color, shape и fill) выполняется условие деление суммы rawValue всех 3-х карт нацело на 3, то это и есть условие того, что эти 3 карты составляют сет Set:

static func match(cards: [SetCard]) -> Bool {
guard cards.count == 3 else {return false}
let sum = [
cards.reduce(0, { $0 + $1.number.rawValue}),
cards.reduce(0, { $0 + $1.color.rawValue}),
cards.reduce(0, { $0 + $1.shape.rawValue}),
cards.reduce(0, { $0 + $1.fill.rawValue})
]
return sum.reduce(true, { $0 && ($1 % 3 == 0) })
}

Для вычисления сумм sum мы использовали функцию reduce c арифметической операцией «+», а для получения окончательного результата также функцию reduce, но c логической операцией «&&» (логическое «И»). За счет этого мы добились такого простого кода для функции match.

Итак, ViewModel готова, давайте использовать её в View.

View для игры SetGame<SetCard>.

Для View в подсказках к Заданию 3 нам предлагают использовать сетку Grid:

Не стесняйтесь использовать «сетку» Grid для раскладки карт, если хотите. Однако вы не обязаны этого делать. Вы также можете изменить Grid, если хотите, но в этом нет необходимости.

Для «сетки» Grid добавляем в наш проект некоторые вспомогательные файлы из демонстрационного примера Лекции 4.

А для самого View игры SetGame<SetCard> добавить SwiftUI файл с именем SetCardGameView.swift и разместим в нем очень простой код, схожий с демонстрационным примером Лекции 4:

Здесь мы используем нашу ViewModel в качестве @StateObject переменной var viewModel, изменения которой будут автоматически вызывать перерисовку нашего SetCardGameView, в основе которого лежит массив карт viewModel.cards на игровом столе, расположенных в виде «сетки» Grid. Мы также предоставляем игроку возможность выбора карты, если он кликнет на неё, с помощью «Намерения» viewModel.choose (card:) . Кроме того, мы используем модификатор .onAppear для «сетки» Grid, чтобы «сдать» карты в начале игры с помощью «Намерения» viewModel.deal( ).

Для каждой карты card, лежащей на игровом столе и имеющей ТИП SetGame<SetCard>.Card, мы используем отдельный CardView, который будет настраиваться исключительно на свойства самой Card в игре SetGame, то есть на свойства isSelected, isMatched, isNotMatched, а «отрисовку» содержимого карты card.content мы отдаем уже разработанному нами выше CardSetView.

Вот как будет выглядеть наш SetCardGameView :

Визуализация совпавших карт.

Вы уже можете кликать на картах, они будут совпадать или не совпадать, но мы этого видеть не будем. Для более ясной картины происходящего c картами требуется визуально показать сам факт выбора карты, факт того, что выбранные 3 карты составляют или НЕ составляют сет Set. Давайте покажем это с помощью определенного цвета, обрамляющего «выбранную» или «совпавшую / не совпавшую» карту. Сделать это очень легко c помощью функции highlightColor и обрамления карты с помощью overlay:

Теперь наши действия с картами будут более осмысленными: «совпавшие» карты будут выделяться голубым цветом, «не совпавшие» — красным, а просто «выбранные» карты — желтым цветом:

«Полет» карт с помощью Transition.

Мы видим, что при обнаружении сета Set, когда 3 карты окрашиваются голубым цветом, а мы выбираем произвольную карту, 3 карты, составляющие сет Set, заменяются на новые. Но это происходит незаметно, а нам в Задании 3 требуется показать эту замену карт динамически, то есть одни карты «улетают» за пределы экраны, а новые «прилетают на их место.

    1. совпадающие (matching) карты должны улетать (с анимацией) в случайные места за пределами экрана
    2. заменяющие карты должны прилететь (с анимацией) из других случайных мест за пределами экрана (или из «колоды» где-нибудь на экране, см. Дополнительные пункты)

В Задании 3 нам предлагают, как можно осуществить «полет» карт с помощью transition:

Вот вам некоторая помощь с «летающими» картами …

  1. Все обязательные пункты этого Задания для «летающих» карт просто связаны с Views, представляющих карты, которые «приходят и уходят» из UI.
  2. «Приходы и уходы» Views в SwiftUI анимируются с помощью transitions (как описано в лекции).
  3. «Улет (прилет)» — это просто перемещение. Перемещение от того места, где они в конечном итоге будут (или были) на экране, в какое-то случайное место за пределами экрана. Так что вам нужно иметь простую функцию где-нибудь в вашем приложении, которая вычисляет случайное местоположение за пределами экрана (куда карта может улететь или откуда прилетать).
  4. В SwiftUI есть идеальный transition для перемещения под названием AnyTransition.offset(CGSize) (который анимирует тот же ViewModifier, что и модификатор .offset в View).
  5. Убедитесь, что вы помещаете модификатор .transitions в Views, которое действительно «приходит на экран и уходит с экрана», а, например,  не в какой-то стек Stack, внутри которого находится этот View.
  6. Карты не будут «приходить на экран и уходить с экрана» должным образом, если ваша Model не будет четко представлять, какие карты в настоящее время участвуют в игре. Карта уже сдана? Карта уже «совпала и сброшена»? Подобные вещи должны быть где-то в вашей Model, иначе пользовательский интерфейс (UI) не будет знать, какие карты должны быть на экране в данный момент.
  7. Ваш View, как всегда, просто отображает состояние Model. Так, например, массив Identifiables в Grid (при условии, что вы используете Grid для отображения своих карт) не будет включать карты, которые ещё не были сданы или которые уже «совпали» и сброшены.
  8. Помните, что transition анимация не происходит, если вы не будете ее явно анимировать. Таким образом, вам потребуются любые действия пользователя, которые могут вызвать явную анимацию летающих карточек с помощью withAnimation .
  9. Не забывайте об .onAppear . Это может быть очень полезно для запуска игры, когда ваш пользовательский интерфейс впервые появляется на экране.

«Приходы и уходы» Views в SwiftUI анимируются с помощью «переходов» transition и это просто перемещение от того места, где они в конечном итоге будут (или были) на экране, в какое-то случайное место за пределами экрана. Так что у нас будут две простые функции flyTo и flyFrom, которые вычисляют случайное местоположение карты за пределами экрана, когда она «прилетает» или «улетает». Для этих вычислений нам понадобиться GeometryReaderSwiftUI есть идеальный «переход» transition для перемещения под названием AnyTransition.offset (CGSize) :

И вот наш код:

Мы создали ассиметричный «переход» AnyTransition.asymmetric для наших карт — они «прилетают» из одного места (снизу), а «улетают» — в другое (наверх). А также мы усилили ситуацию с «полетом» карт, сделав «улетающие» карты меньшего размера. Для этого мы добавили к ассиметричному «переходу» removal «переход» AnyTransition.scale (CGFloat), чтобы как-то разделить, какие карты «улетают», а какие — «прилетают»:

«Переходы» transition не будут работать, если не добавить анимацию. Анимацию можно добавить в 3 места: либо непосредственно к тому View, которое «приходит» и «уходит», либо к его Parent View, либо к тем действиям, которые заставляют View «уходить» и «приходить». Первые два варианта связаны с неявной анимацией: а третий — с явной анимацией. Профессор Пол Хэгерти в своей Лекции 6 настоятельно рекомендует использовать явную анимацию с «переходами» transition, и мы выбрали третий вариант и добавили явную анимацию withAnimation, к появлению карт на экране в .onAppear и при выборе карты с помощью жеста Tap. Теперь наши действия с картами будут сопровождаются анимацией, которую мы заложили в withAnimation:

Мы видим, что карты «улетают» и «прилетают» при обнаружении сета Set. Они также анимируют в самом начале игры, но они появляются все сразу, а нам бы хотелось имитировать «сдачу» карт, когда они появляются одна за другой с небольшой задержкой.

Сдача карт.

Попробуем имитировать раздачу карт в самом начале игры, когда карты появляются на игровом столе одна за другой. Для этого нужна проводить анимацию появления карт на игральном столе с задержкой cardTransitionDelay, причем задержка в анимации должна увеличиваться для каждой последующей карты и действовать только во время сдачи карт, а не во время замены карт, образующих сет Set. Так что у нас появляется @State переменная var shouldDelay: Bool, которая будет управлять тем, когда нам требуется задержка анимации карт, её начальное значение равно true, так как в начале игры идет сдача карт, требующих задержки. Как только сдача карт закончится, переменная shouldDelay устанавливается в false. Кроме того, мы уже не сможем использовать явную анимацию, которая действует на все карты одинаково, нам нужна неявная анимация, так как для каждой карты будет своя задержка:

Но если мы запустим приложение, то увидим, что никакой задержки при сдачи карт не происходит:

В чём дело?
А дело в том, что если мы посмотрим на код «сдачи» карт, который находится в модификаторе .onAppear, то окажется, что строка кода, которая устанавливает переменную shouldDelay в false, выполняется в неподходящее время :

Мы хотим изменить наш View только после того, как закончится его обновление, вызванное со сдачей карт. Но изменение переменной shouldDelay происходит во время обновления View и не дает сработать задержке. Нужно подождать, пока всё успокоится. Так что я не буду устанавливать переменную shouldDelay в false до тех пор, пока не буду уверена, что мой UI уже обновился.

Я собираюсь этого добиться с помощью DispatchQueue.main.async:

Это не имеет ничего общего с многопоточностью. Мы ничего не делаем в фоновом (background) потоке. Просто я прошу main queue выполнить это замыкание, она внесет его в свой список на выполнение, и даже если этот код выполняется на main queue прямо сейчас, возможно, даже при обновлении моих Views, main queue сначала закончит делать обновление моего View, а потом обратится к своей очереди на выполнение, захватит следующее замыкание, надеюсь, это будет моё замыкание, и выполнит его.

По сути, это всё равно что сказать: “Сделай это после того, как все уляжется”.

Давайте посмотрим, помогли нам это с задержкой карт при «сдаче»:

Да, всё прекрасно работает.

И выделим «сдачу» карт в отдельную вспомогательную функция deal( ) :

Теперь body нашей игры выглядит немного проще :

Давайте проведем ещё небольшое упрощение кода нашего UI в преддверии усложнения нашего UI, а именно извлечем наш asymmetric transition в static свойство AnyTransition и обеспечим доступ к нему через модификатор .transition нашего CardView , это сделает код более понятным и расширит наш арсенал «переходов» :

Мы разместили этот код в файле Transition+Extensions.swft и body нашей игры выглядит ещё проще :

Сколько карт осталось в колоде, добавить ещё 3 карты и новая игра.

Давайте добавим на наш UI метку, которая будет показывать, сколько карт осталось в колоде, кнопку для добавления 3-х карт на игровой стол и кнопку для новой игры. Но прежде мы должны обеспечить в нашем ViewModel доступ к Модели и «Намерение» (Intent) :

На UI добавляем Text с количеством карт в колоде, кнопку Button «Deal+3» для сдачи дополнительных 3-х карт и кнопку Button «New Game» для новой игры:

Действия, которые выполняются при нажатии кнопок «Deal+3» и «New Game», оформляются в виде вспомогательных функций deal3() и newGame(), на подобие функции deal() :

Теперь мы можем контролировать оставшееся количество карт в колоде, добавлять 3 карты в игру и начинать новую игру:

Отдельный GameView.

Давайте ещё немного упростим наш основной ViewSetCardGameView. Так как не рекомендуется иметь очень большие View, то выделим всё, что относится к «сетке» Grid, в отдельный View :

Назовем новый View — GameView :

В новый GameView перенесем необходимые константы и переменные viewModel и shouldDelay, которые будем задавать при инициализации таким образом, чтобы обеспечить единственный источник информации (single source of truth):

Модификатор .onAppear( ) перенесем назад в SetCardGameView. Единственный источник информации (single source of truth) в «дочернем» View обеспечивается заменой @StateObject на @ObservedObject и @State на @Binding.

Ни логика игры, ни UI не изменились, но в основном SetCardGameView код стал более лаконичным, что позволит нам размещать и другие элементы UI из дополнительных пунктов Задания 3. Из дополнительных пунктов Задания 3 я буду разрабатывать пункт 8, в котором предлагается реализовать «мошенническую» кнопку с подсказками, какие карты, лежащие на игровом столе, составляют сет Set.

Мошенническая кнопка с подсказками Hints.

Пункты 8 дополнительный.

8. Добавьте кнопку «мошенничество» в свой интерфейс. Если вы напишете алгоритм определения Sets, то сможете добавить «мошенническую» кнопку для того, чтобы испытывающий трудности пользователь смог использовать ее для нахождения Set!

Имея карты cards, лежащие на игровом столе, и возможность определять, являются ли произвольные numberOfCardsToMatch карты сетом Set, с помощью функции match, мы можем очень легко определить все сеты Set, которые в данный момент лежат на столе, с помощью вычисляемой переменной var hints: [[Int]], которая содержит индексы карт. составляющих, «мошеннические» сеты Set:

Как мы сможем показать пользователю эти «мошеннические» сеты Set, если он отчается сам найти их?

На UI у нас будет специальная кнопка, кликнув на которую, мы на очень короткое время «мигнем» каким-то образом теми картами, которые составляют сет Set. Для этого мы должны уметь выделять карты, входящие в сет Set, составленный по «мошенническим» подсказкам hints. Добавим к уже имеющимся свойствам абстрактной карты Card свойство isHint:

Ясно, что «мошеннических» сетов Set может вообще не быть, а может быть сколько угодно. И мы хотим «пройтись» по всем этим «мошенническим» сетам Set, чтобы показать их игроку в случае необходимости. Для этого мы будем хранить в переменной var numberHint номер текущего «мошеннического» сета Set, то есть того, который в данный момент мы показываем пользователю:

У нас будут две функции. Одна — hint(), которая присваивает свойству isHint карт, входящих в текущий «мошеннический» сет Set, значение true:

Другая функция — deHint(), будет присваивает свойству isHint всех карт, лежащих на игровом столе, значение false :

Вызов этих функций с некоторой временной задержкой и обеспечит «мигание» «мошеннических» сетов Set.

С помощью функции hint( ), при каждом её вызове мы будем последовательно циклически проходить по мошенническим» сетам Set, начиная с 0-го сета, доходить до hints.count сета и опять возвращаться к 0-ому сету и опять идти до hints.count. Каждый раз, когда карты заменяются или удаляются, мы будем присваивать переменной numberHint:

Давайте добавим в ViewModel «Намерение» hint( ) :

… и информацию numberHint о том, какой «мошеннический» Set доступен в данный момент пользователю для просмотра:

Вы видите временную задержку между вызовами функций model.hint() и model.deHint() в 1 миллисекунду, реализованную с помощью DispatchQueue.main,asyncAfter.

В View мы добавляем кнопку с подсказками о «мошеннических» сетах Set:

и делаем цвет фона карты CardView зависимым от свойства карты isHint:

Теперь нам намного легче играть в игру Set:

Особенности добавления 3-х дополнительных карт.

В Задании 3 указаны определенные условия для кнопки сдачи 3-х дополнительных карт:

11. Вам необходимо также иметь кнопку «Deal 3 More Cards» (Сдай еще 3 карты) (согласно правилам игры Set).

    1. при касании этой кнопки замените выбранные карты, если выбранные карты составляют Set (с анимацией прилета /улета, как описано выше)
    2. или, если выбранные карты не составляют Set  (или если выбрано менее 3 карт, в том числе и ни одной), организуйте прилет (т. е. анимируйте прибытие) 3 новых карты, чтобы присоединиться к уже имеющимся на экране (и не делайте их выбранными (selected))
    3. Отключите эту кнопку, если колода пуста.

Мы должны не просто сдать 3 дополнительные 3 карты, а действовать в зависимости от того, имеем ли мы на игровом столе сет Set или нет:

Если у нас сет Set, то мы просто заменяем карты, если нет — то добавляем дополнительные 3 карты, которые после обнаружения нового сета Set уйдут с игрового стола:

Если колода заканчивается, то мы отключаем кнопку «Deal+3» с помощью кода:

Вот как будет выглядеть эта кнопка в конце игры:

«Отмена выбора» (deselection) уже выбранной карты.

8. Поддержите «отмену выбора» («deselection«), путем повторного касания уже выбранных карт (но только если в данный момент выбраны 1 или 2 карты (не 3)).

Для этого немного усовершенствуем основной метод choose(card:Card) в нашей Модели игры SetGame:

В этом методу по сути заключена вся логика игры Set, и теперь мы можем выполнять операцию «отмены выбора» для уже выбранных карт:

Собираем все визуально ориентированные вещи в ViewModel.

8 подсказка. Вероятно, было бы хорошим дизайном MVVM не встраивать «ориентированные на отображение» вещи, такие как цвета или даже имена символов (ромб, овал, прямоугольник) и видов заполнения символов (пустой, заштрихованный, закрашенный) в вашу Model. Представьте, что у вас есть темы для игры Set, как и для Memorize. Помните, что ваша Model почти ничего не знает о том, как игра будет представлена пользователю. Почему бы не Set карт с пингвинами?

Добавляем структуру struct для визуальных настроек с именем Setting и два перечисления enum FillInSet и ShapesInSet:

Свойствами структуры Setting являются:

  • набор цветов colorsShapes символов в игре Set,
  • набор цветов colorsBorder для рамок карт, если они являются «выбранными» (isSelected), если они входят в сет Set (isMatched) и если они не входят в сет Set (isNotMatched),
  • цвет colorHint для «подсветки» «мошеннических» сетов Set при подсказке,
  • набор символов shapes для игры Set,
  • варианты заполнения символов fillShapes

В нашей ViewModel будет переменная var setting ТИПа Setting

… и мы сможем использовать её для настройки CardView :

и SetCardView:

В SetCardView мы аккумулировали все возможные геометрические фигуры (добавили «дождевую каплю» RainDrop) и все возможные способы заполнения фигур ( добавили «затушевывание» blur()). Настройка на нужные геометрические фигуры и способы их заполнения производится с помощью настроек setting.

С помощью настроек setting мы можем играть не только в классическую игру Set, но и в стилизованную игру Set с другими цветами, фигурами типа «дождевой капли» и способами заполнения символов типа «затенение»:

Код находится на Github.