Задание 3 Stanford CS 193P Fall 2017. Графическая игра Set. Решение обязательных пунктов.

Содержание

Цель этого задания — в получении опыта создания своих собственных пользовательских (custom) view, включая управление пользовательскими жестами.
Начните свой код с Задания 2.
Текст Домашнего задания на английском языке доступен на  iTunes в пункте “Programming: Project 3:Graphical Set″. На русском языке вы можете скачать Задание 3 здесь:

Задание 3 Игра Графический Set iOS 11.pdf

Начинаем выполнять с кода Задания 2.

Правила игры Set:

SET INSTRUCTIONS - RUSSIAN.pdf

Для решения Задания 3 необходимо ознакомиться с Лекциями 6 — 8.

Мое решение Задания 3 состоит из основного приложения Set III NoExtra и вспомогательного SetCard для отдельной карты, они находятся на Github  для iOS 11 и на Github для iOS 12.

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

1. Ваше приложение должно продолжать играть в соло версию игры Set, как это требовалось в Задании 2.

2. В этой версии не нужно ограничивать пользовательский интерфейс (UI) фиксированным числом карт. Вы всегда должны быть готовы к тому, что могут быть сданы еще 3 карты с помощью кнопки “Deal 3 More Cards”. Если карт в колоде больше не осталось, то кнопка “Deal 3 More Cards” исчезает.

3. Не “отводите заранее” место для максимально возможного количества карт — 81. В любое заданное время карты должны иметь настолько возможно большой размер, насколько это позволит отведенное для них место на экране и количество карт, находящихся в игре. Другими словами, когда игра начинается (только с 12 -ю картами), карты будут реально большими, но по мере все большего количества карт на экране (благодаря кнопке “Deal 3 More Cards”), они будут становиться все меньше и меньше для того, чтобы соответствовать размеру игрового поля.

4. В конце игры, когда обнаружены 3 совпавшие карты и больше нет Set карт в колоде, совпавшие карты должны быть полностью убраны и оставшиеся карты должны “пере-формироваться”, чтобы использовать освободившиеся покинувшими игру картами пространство экрана с экрана (то есть стать опять немного больше, если это позволит размер игровой области на экране).

Начинаем наш проект с модификации проекта Задания 2, который находится на GitHub. Давайте посмотрим, как выглядит storyboad в Задании 2 и как нам предстоит ее модифицировать в Задании 3.

Убираем большой Stack View c кнопками-картами и размешаем вместо него обычный UIView, который вытягиваем из Палитры Объектов. В нем мы будем  программным образом формировать наши карты игры Set . Ограничения (constraints)  системы Autolayout для нового UIView выставляем точно такие же, какими они были для Stack View c кнопками:

Для нового UIView создаем и назначаем пользовательский класс BoardView:

Класс BoardView — это subclass класса UIView. Его Моделью является массив карт cardViews игры Set: [SetCardView]. При установки массива cardViews извне, мы обращаемся с его элементами как subviews, удаляя старые карты и размещая новые с помощью  класса Grid, предоставленного нам профессором:

Нам достаточно задать соотношение сторон для карты SetCardView, размер сетки, в нашем случае bounds, и указать число ячеек этой сетки, чтобы класс Grid предоставил в наше распоряжение frame для каждой ячейки, а, следовательно, и для нашей карты:

Для того, чтобы мы могли располагать наши карты на BoardView в коде, нам, естественно необходим Outlet на него:

Центральным методом нашего класса SetGameViewController является метод updateViewFromModel(), устанавливающий соответствие между View (cardViews, кнопками, метками) и Моделью (игрой game) в паттерне MVC:

Самым интересным в этом методе является вызов метода updateCardViewsFromModel(), который устанавливает соответствие между графическим изображением Set карт boardView.cardViews, лежащих в данный момент на игровом столе, и их Моделью game.cardsOnTable, полученной из игры game ТИПА SetGame:

Этот метод очень чутко реагирует на число карт game.cardsOnTable, оставшихся на игровом поле в Моделе, пытаясь синхронизировать это число карт с количеством карт в визульном игровом поле boardView.

Отметим одну особенность нашей игры. Допустим, что число карт игровом поле больше 12 (например, 15 карт), которые получились вслествие того, что игрок добавил три дополнительные карты с помощью кнопки «Deal 3+«. Допустим, что игроку удалось обнаружить Set, и выбор любой следующей карты приведет к тому, что совпавшие карты удаляются с игрового поля.

Дальше есть два варианта развития игры Set. В первом классическом варианте игры Set удаленные с игрового поля совпавшие карты ничем не заменяются, поддерживая на игровом поле ровно 12 карт, как это было вначале игры. Во втором варианте удаленные с игрового поля совпавшие карты заменяются новыми картами, которые берутся из колоды карт, если они там остались. Мы будем работать с первым класическим вариантам игры Set, который стремится поддерживать на игровом поле 12 карт. Это более трудный вариант игры Set, так как мы постоянно будем переключаться с 12 карт на 15 карт или больше и обратно, но это позволит нам лучше отладить наше приложение.

Если карт game.cardsOnTable в Модели по каким-то причинам (например, в конце игры, когда мы не можем компенсировать совпавшие и удаленные карты из колоды карт) стало меньше, чем карт в визуальном  игровом поле boardView, то мы удаляем на boardView лишние карты. 

Если карт game.cardsOnTable в Модели по каким-то причинам (например, игрок добавил три дополнительные карты с помощью кнопки «Deal 3+«) стало больше, чем карт в визуальном  игровом поле boardView, то создаем новые недостающие карты в визуальном  игровом поле boardView, при этом мы добавляем на новые карты жест tap, с помощью которого мы будем взаимодействовать с этой картой в процессе игры Set. Добавление жеста tap производится с помощью функции addTapGestureRecognizer(for: cardView).

Для оставшихся карт мы просто обновляем их графическое представление с помощью функции updateCardView(cardView, for: card) в соответствии с состоянием карты card в Модели игры game.

При выборе карты с помощью жеста tap мы, в конечном итоге, запускаем метод chooseCard(at index: Int) Модели SetGame игры Set, хорошо знакомый нам из домашнего Задания №2:

Мы видим, что в методе addTapGestureRecognizer(for: cardView) создается жест tap, у которого обработчик жеста находится в нашем классе SetGameViewController (так как аргумент target инициализатора установлен в self) и имеет имя tapCard (recognizedBy:) (так как аргумент action — это соответствующий селектор). Мы добавляем жест tap нашей графической карте cardView.

Обработчик tapCard (recognizedBy:) жеста tap, определяет графическую карту cardView, на которой игрок «тапнул», и сообщает об этом Модели, вызывая метод game.chooseCard(at index: Int). Естественно, это изменяет состояние карт в игре, и мы должны синхронизировать наш UI с Моделью с помощью метода updateViewFromModel().

Для того, чтобы все это работало нам нужно графическое изображение карты Set.

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

5. Карты должны иметь “стандартный” вид и наполнение  (то есть 1, 2 или 3 загогулины (squiggles), ромбов (diamonds) или овалов (ovals), которые плотно закрашены (solid), заштрихованы (striped) или не заполнены (unfilled) и быть либо зеленого (green), красного (red) или фиолетового (purple) цвета). Вы можете рисовать их, используя кривые Безье UIBezierPath и/или функции Core Graphics. Вы НЕ можете использовать НИ строки с атрибутами, НИ изображения UIImages для рисования ваших карт.

6. Каким бы способом вы не рисовали ваши карты, они должны уметь “масштабироваться” в соответствии с размером карты (очевидно, для удовлетворения обязательному пункту 3).

7. На картах, у которых более одного символа, вам разрешается рисовать символы по горизонтали в строку или по вертикали в столбец (или даже в зависимости от коэффициента пропорциональности (aspect ratio) рисуемой в данный момент карты).

Мы поступим также, как поступил профессор при проектировании игральной карты. Он проектировал карту в отдельном приложении на Лекции 6. Мы возьмем его приложение PlayingCard L6, которое находится на Github  для iOS 11  и на Github для iOS 12 в папке PlayingCard L6,  и «подгоним» его к нашей Set карте. Оно удобно тем, что для проверки полученного изображения нам даже не придется запускать приложение, достаточно посмотреть на storyboard. Это своеобразный «полигон» для отработки графического изображения Set карты. Это экспериментальное приложение SetCard находится на Github для iOS 11 и на Github для iOS 12.

Для начала мы создаем новый класс SetCardView, который является subclass класса UIView, и устанавливаем его в качестве пользовательского (custom) класса для графического изображения Set карты.

В классе SetCardView свойства Set карты описываются уже не абстрактными перечислениями ТИПА Variant, а вполне  конкретными структурами данных Fill, Symbol, Colors, обеспечивающими стандартный вид Set карты:

  • count —  число символов
  • color — цвет символов
  • fill — заполнение символов
  • symbol — символ

С этими смысловыми структурами данных очень удобно работать внутри класса SetCardView, но в качестве public API мы не можем его предложить, потому что нам придется «тащить» их все в класс SetGameViewController, обслуживающий наш MVC, доставшийся нам из Задания 2 и работающий с абстрактными свойствами Set карты, что было одним из самых важных требований Задания 2. Поэтому в качестве public API класса SetCardView предложим Int аналоги всех свойств Set карты, за исключением свойства count, которое и так уже является Int:

Все свойства имеют атрибут @IBInspectable и, следовательно, их можно задавать прямо на storyboard:

Помимо свойств symbol, fill, color, count, определяющих внешний вид Set карты, есть свойства, определяющие состояние карты в игре Set: «выбранная» isSelected, «совпавшая / не совпавшая / не участвовала в тесте на совпадение» isMatched, «лежащая лицевой стороной вверх» isFaceUp, цвет фона faceBackgroundColor. Как только любое из этих свойств устанавливается, UIView нуждается в перерисовке::

Все свойства имеют атрибут @IBInspectable и, следовательно, их можно задавать прямо на storyboard. Но нужно помнить, что для свойств, имеющих атрибут @IBInspectable необходимо ЯВНО задавать ТИП свойства, и этот ТИП должен быть совместимым с ТИПАМИ в Objective-C, потому что Interface Builder —  это не Swift, и у него нет механизма «вывода ТИПА из контекста».  Именно поэтому мы не сможем показать свойство isMatched, которое имеет Optional ТИП:

Теперь приступим к рисованию.

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

В зависимости от количества символов на карте count рисуем соответственно 1, 2 или 3 символа :

В зависимости от типа символа symbol рисуем «волну» (squiggle), ромб (diamond) или овал (oval), а в зависимости от типа заполнения символа fill оставляем символ как он есть, закрашиваем его или штрихуем :

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


«Ромб» (diamond) рисуем с помощью обычных линий:


Овал» (oval) рисуем как комбинацию линий и дуг:

Самым тяжелым элементом этого Задания оказалось закрашивание фигуры полосками с помощью Core Graphics. В подсказках к Заданию 3 особо оговаривается этот случай:

Подсказки 4 и 5.

4. Заметим, что 3-ий тип “заполнения” карт — это “штриховка” (striping) (не “затенение”, как было на прошлой неделе). Возможно, “отсечение” по траектории с помощью метода addClip было бы полезно для реализации “штриховки”.

5. Если вы используете метод addClip, то все будущие рисования будут “отсекаться” (до самого конца выполнения методов в вашем draw()). Если вы хотите выполнить “undo” для отсечения, то вам нужно “обернуть” с помощью вызовов Core Graphics функций saveGState() и restoreGState()  ту часть вашего рисования, которая вы хотите, чтобы работала с “отсечением”. Функция saveGState() полностью сохраняет текущий графический контекст (включая “отсечение” (clipping)) на момент ее вызова, а функция restoreGState() возвращается к сохраненному графическому контексту. Вы посылаете эти функции графическому контексту, который вы получаете путем вызова UIGraphicsGetCurrentContext().

На настоящий момент мне известно два варианта закрашивания полосками:

  1. Классическое рисование горизонтальных или вертикальных линий.
  2. Использованием пунктирной линии с шириной во всю высоту карты:

Самый остроумный и легкий — способ с пунктирной линией (одной линией в отличие от первого способа, когда рисуется множество линий).

Шриховка выполняется в методе stripeShape(path: UIBezierPath, in rect: CGRect), там используются все рекомендации, данные в подсказках 4 и 5, а именно применяется метод addClip отсечения по траектории, а сама штрифовка, метод stripeRect(rect) оборачивается функциями saveGState() и restoreGState() :

Как я уже сказала, штрифовка может выполняться 2-мя способами, и в каждом из них мы можем рисовать, фактически, на всей карте, не заботясь строго о границах штрихуемого нами символа, так как ранее был выполнен метод  addClip отсечения по траектории, а сама штрифовка, метод stripeRect(rect) оборачивается функциями saveGState() и restoreGState().

Вот 1-ый классический способ штриховки прямфми линиями:

и результат:

Вот 2-ой остроумный способ штрифовки с помощью одной специально подобранной пунктирной линией шириной на всю карту:

и результат:

Результаты практически не отличаются.

Пункт 6 обязательный

6. Каким бы способом вы не рисовали ваши карты, они должны уметь “масштабироваться” в соответствии с размером карты (очевидно, для удовлетворения обязательному пункту 3).

Мы можем это проверить, если зададим на storyboard ландшафтный режим :

Пункт 7 обязательный

7. На картах, у которых более одного символа, вам разрешается рисовать символы по горизонтали в строку или по вертикали в столбец (или даже в зависимости от коэффициента пропорциональности (aspect ratio) рисуемой в данный момент карты).

На нашей карте символы располагаются вертикально.

Пункт 8 обязательный

8. Жест tap, выполняемый на карте, должен делать карту выбранной/ невыбранной.

Ранее мы уже говорили о том, что при создании новых графических Set карт в визуальном игровом поле boardView в нашем Controller мы добавляем на новые карты жест tap, с помощью которого мы будем взаимодействовать с этой картой в процессе игры Set:

 Добавление жеста tap производится с помощью функции addTapGestureRecognizer(for: cardView)

При выборе карты с помощью жеста tap мы, в конечном итоге, запускаем метод chooseCard(at index: Int) Модели SetGame игры Set, хорошо знакомый нам из домашнего Задания №2:

Мы видим, что в методе addTapGestureRecognizer(for: cardView) создается жест tap, у которого обработчик жеста находится в нашем классе SetGameViewController (так как аргумент target инициализатора установлен в self) и имеет имя tapCard (recognizedBy:) (так как аргумент action — это соответствующий селектор). Мы добавляем жест tap, нашей графической карте cardView.

Обработчик tapCard (recognizedBy:) жеста tap, определяет графическую карту cardView, на которой игрок «тапнул», и сообщает об этом Модели, вызывая метод game.chooseCard(at index: Int). Естественно, это изменяет состояние карт в игре, и мы должны синхронизировать наш UI с Моделью с помощью метода updateViewFromModel(). Так как мы должны обеспечить визуализацию «выбранной» графической Set карты, то нас в данный момент интересует только та часть метода updateViewFromModel(), которая отвечает за обновление карт, а именно метод updateCardViewsFromModel(), который представлен выше. В нем установление соответствия визуального графического образа каждой Set карты cardView и Модели  Set карты card осуществляется с помощью метода updateCardView(cardView,for: card):

Cейчас, когда нам известен public API класса SetCardView, представляющего  графический образ  Set карты, мы можем рассмотреть подробно метод  updateCardView(cardView,for: card), который располагается в Controller и выполняет работу Controller по синхронизации Model и View. Как мы и предполагали,  целочисленный Int public API класса SetCardView очень легко адаптировать к свойствам shape, fill, color, number карты card  в Model, достаточно взять их rawValue.

Является ли карта card «выбранной», определяется  присутствием ее в множестве «выбранных» карт game.cardsSelected, получаемой нами из Model game. Если карта «выбрана», то свойству isSelected ее визуальный аналог cardView присваивается значение true, а если нет — то false.

Аналогично определяется свойство isMatched визуального аналога cardView карты card, но здесь ситуация несколько сложнее и представлена она Optional ТИПОМ Bool, который дает нам 3 состояния:  true, false и nil. Значение nil соответствует состоянию игры Set, когда еще не сформировались 3 карты, которые можно анализировать на Set.

Итак, мы уже умеем устанавливать свойства isSelected и isMatched визуальному аналогу Set карты cardView, но мы не умеем реагировать на эти свойства в классе  SetCardView. Возвращаемся в класс SetCardView и напишем код для конфигурации нашей графической карты SetCardView, которая будет зависеть от свойств isSelected и isMatched:

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

Но не только цветными рамками мы будем выделять выбранные», «совпавшие» и «несовпавшие» карты, мы прикрепим к ним «булавку» как subview.

Вот как выглядит «выбранная» карта в портретном режиме:

и в ландшафтном режиме:

Отметим, что наша «булавка» «масштабируется» в соответствии с размером Set карты.

Для отображения «булавки» pinLabel используется тот же прием, что использовал профессор для размещения текста на углах игральной карты в Лекции 6.

Масштабирование «булавки» в зависимости от размера карты SetCardView осуществляется в методе layoutSubviews():

Вот как выглядят «совпавшие» и «несовпавшие» карты:

Есть возможность «подсветить» извне любую карту с помощью public метода hint():

Этот метод используется для кратковременной подсветки карт, составляющих Set в нашем Controller (класс SetGameViewController), в качестве подсказке пользователю:

Когда истечет время подсветки flashTime, прежнее визуальное состояние Set карт восстанавливается с помощью метода updateCardViewsFromModel(). Вот как это выглядит:

Пункты 9 и 10 обязательные

9. Жест swipe down (“смахивание” вниз) в вашей игре должен вызывать нажатие кнопки “Deal 3 More Cards”.

10. Добавьте жест rotation (два пальца вращаются как бы настраивая радиоприемник) должен вызывать случайное перемешивание (reshuffle) карт.  (Это полезно, если пользователь “застрял” и не может найти Set). Это может потребовать модификации вашей Модели.

При установке игрового поля boardView добавляем оба жеста:

Для жеста swipe down указанный в #selector метод deal3 уже существует, но в этом случае нам не нужно ставить перед ним атрибут @obj, так как Swift 4 косвенно (implicitly) добавляет атрибут @obj в отдельных очень ограниченных случаях:  @IBAction@IBOutlet@IBInspectable или @NSManaged, а также для методов UITableViewSource:

Для жеста rotate нам пришлось создать метод reshuffle (), указанный в #selector, и поставить перед ним атрибут @obj:

Нам также пришлось в Модели, в игре SetGame, обзавестись новым public mutating методом shuffle():

В этом методе мы для карт cardsOnTable, лежащих на игровом столе. использовали метод shuffle() перемешивания по месту элементов массива Array, который мы определили в расширении extension массива Array:

Вот жест  swipe down в действии:

Вот жест rotate в действии:

Пункт 11 обязательный

11. Ваша игра должна правильно и хорошо выглядеть в обоих режимах ориентации : в ландшафтном и портретном, а также на всех iPhones и iPads. Она должна эффективно использовать все доступное пространство при любых обстоятельствах.

Для iPhones у нас будет по-разному располагаться Stack View с управляющими кнопками «4 sets«, «New Game«, «Deal 3 +«: в портретном режиме этот стек будет располагаться внизу, а в ландшафтном — сбоку, справа, чтобы сгруппировать карты и облегчить пользователю поиск Sets:

Этого можно добиться, если использовать методику, которую профессор предложил для игры Concentration в Лекции 10. Единственное, что нам необходимо сделать дополнительно, это установить разный тип  Stack View : для Compact Size Class по ширине width это будет горизонтальный стек Horizontal, а для всех остальных Size Class —  вертикальный стек Vertical:

Кроме того, расстояние между этими управляющими кнопками будет существенно больше для устройств с Regular Size Class по ширине width и с Regular Size Class по высоте height, то есть любых iPad.

Размер шрифта для управляющих кнопок и меток для устройств с Regular Size Class по ширине width и с Regular Size Class по высоте height, то есть любых iPad также  увеличен:

Вот как выглядит выполнение жеста swipe down на iPhone в ландшафтном режиме:

Выполнение жеста swipe down на iPad в портретном режиме:

Выполнение жеста swipe down на iPad в ландшафтном режиме:

Код основного приложения и вспомогательного для отдельной карты находятся на Github для iOS 11 и на Github для iOS 12.

Осталось ответить на вопрос, который задается в подсказке № 5 к Заданию 3.

Можете вы не изменять Модель, которую вы сформировали на прошлой неделе ( за исключением нового требования о перемешивании карт в ответ на жест rotation)? Если нет, то почему? Понимание этого, возможно, поможет вам с пониманием концепции MVC в целом.

Мы даже не притронулись к коду, моделирующему Set карту, то есть к структуре struct SetCard, и добавили только public метод shuffle() в «движок» игры Set структуру struct Game. Это говорит о том, что нам удалось воспользоваться преимуществами паттерна MVC и полностью изменить View, не изменяя Model.

Задание 3 Stanford CS 193P Fall 2017. Графическая игра Set. Решение обязательных пунктов.: 2 комментария

  1. «Для жеста swipe down указанный в #selector метод deal3 уже существует, и нам достаточно поставить перед ним атрибут @obj:»
    Здесь атрибут @obj не нужен, @IBAction уже Objective-C.

    • Да, вы абсолютно правы. Swift 4 косвенно (implicitly) добавляет атрибут @obj в отдельных очень ограниченных случаях:  @IBAction, @IBOutlet, @IBInspectable или @NSManaged, а также для методов UITableViewSource. Все исправлено.

Обсуждение закрыто.