понедельник, 17 июня 2013 г.

Пасьянс на WPF

Не очень люблю писать игры, но по учёбе всё-таки заставили это сделать. Нужно было написать пасьянс на C# с применением WPF. Долгое время мне казалось, что это очень сложная задача, но как говорится, глаза боятся, а руки делают.

День нулевой


По варианту мне попала разновидность пасьянса Rouge et Noir, ознакомится с правилами предлагалось по пакету SolSuite, скачав который, я половину дня провёл за игрой. Чем больше играл, тем больше лезло мыслей в голову: "Чёрт возьми, как же я буду делать перемещение нескольких карт?!", "Как же я узнаю, куда была перемещена карта?", "Отмена последнего действия, кааааааак?". Так или иначе, собравшись с мыслями, я запустил Visual Studio и создал проект с поддержкой Subversion, чтобы всё отправлялось на GitHub. Тем временем настала ночь и нулевой день закончился...

День первый


"Надо с чего-то начать", - сказал я сам себе и создал подпроект Model. С этого и началось проектирование:
У нас есть карта, какими свойствами она обладает? Масть и значение - очевидно же. А как удобнее всего будет хранить масть и значение? Число? Константы? Enum!!
Готово! Класс Card создан, перечисления CardSuit, CardValue тоже. Чтобы легче было отличать карты, я также написал метод, который переводит информацию о карте в строку:
private string GetCardValueAsString() {
    if (cardValue == CardValue.Ace) return "A";
    else if (cardValue == CardValue.Jack) return "J";
    else if (cardValue == CardValue.Queen) return "Q";
    else if (cardValue == CardValue.King) return "K";
    int value = (int) cardValue;
    return Convert.ToString(value);
}

private char GetSuitChar() {
    return suit.ToString()[0];
}

public override string ToString() {
    return GetSuitChar() + GetCardValueAsString();
}
С картой закончили, теперь нужна колода. Здесь только один вариант - список.
Класс Deck со списком List<Card> и методом Generate создан. Генерировать список из перечислений в C# оказывается так просто:
protected void Generate() {
    foreach (CardSuit suit in Enum.GetValues(typeof(CardSuit))) {
        foreach (CardValue value in Enum.GetValues(typeof(CardValue))) {
            Cards.Add(new Card(suit, value));
        }
    }
}
Немного подумав, я добавил сюда перемешивание колоды:
public static void Shuffle(this IList list) {
    int size = list.Count;
    while (size > 1) {
        size--;
        int index = rnd.Next(size + 1);
        T value = list[index];
        list[index] = list[size];
        list[size] = value;
    }
}
По заданию мне нужно две колоды, то есть 104 карты. Для этого не грех создать новый класс Deck104, наследуемый от Deck и вызвать метод Generate родителя один лишний раз, чтобы добавились еще 52 карты в список. Также, подумав, я добавил картам свойство IsFaceDown, ведь по игре карты не всегда могут быть раскрыты.

На игровом поле у нас нечто большее, нежели карты и колоды. Есть колода - запас, есть колода, куда кладутся карты, есть колода, откуда карты берутся. Логично для каждого такого типа создать свои классы. Начнём со стопок, куда карты складываются как результат. В Rouge et Noir таких стопок восемь, но первые четыре складываются картами одного цвета от туза до короля, а вторые четыре заполняются полностью готовой последовательностью от короля до туза. Нужно эти стопки отделить.
Поэтому я создал абстрактный класс Foundation с методом добавления карты в список и абстрактным методом проверки корректности хода bool IsCorrectMove(Card card). В производных классах LeftFoundation и RightFoundation я реализую только метод проверки, так как именно он отличается.
Таблица с игровыми картами - основная часть игрового процесса. Именно с ними игрок будет иметь дело на протяжении всей игры. Поэтому такие операции, как добавление карт/карты и проверка корректности перемещения должны быть реализованы здесь.
Класс Tableau в результате имеет методы AddCard, AddCards, IsCorrectMove и GetTopCard. На верхнюю карту таблицы можно ложить карту другого цвета в порядке убывания значимости, проверка выглядит так:
private bool IsCorrectMove(Card card, Card top) {
    // На пустую область можно класть только короля.
    if (top == null) {
        return (card.Value == CardValue.King);
    }
    if (card.IsFaceDown || top.IsFaceDown)
        return false;

    bool isAlternatingColor = (top.IsRedSuit() ^ card.IsRedSuit());
    bool isNextCard = (card.Value - top.Value) == -1;

    return (isAlternatingColor && isNextCard);
}
Класс запаса Stock пока что хранил только список карт и больше ничего, так как одно единственное действие "раздать карты по таблицам" не может быть описано только в этом классе. На мой взгляд эта операция должна проводиться где-то выше - в классе игрового поля, который сможет работать со всеми типами колод.
Если взять колоду запаса, колоду игровых таблиц и результирующие стопки, где их всех хранить? Где обрабатывать игровые действия? Должен быть какой-то менеджер или как минимум хранилище этих данных. В контексте карточной игры этим хранилищем как раз может оказаться игровой стол, который будет содержать все необходимые колоды, а также работать с логикой игры.
Последним на сегодня был создан класс GameTable. Полями служили: класс Stock, восемь классов Foundation (из них четыре LeftFoundation, четыре RightFoundation) и десять классов Tableau. Если посмотреть на игровое поле, то сразу становится понятно, почему именно так.
В GameTable создан метод NewGame, в котором создаётся новая колода из 104 карт и оттуда карты перемещаются по таблицам. Метод перемещения из списка в список выглядит так:
public static void Move(List from, List to, int length) {
    var selected = from.Skip(Math.Max(0, from.Count() - length)).Take(length).ToList();
    selected.ForEach(item => from.Remove(item));
    to.AddRange(selected);
}
Сначала выбираем length последних объектов из списка from, потом удаляем все эти элементы, а затем добавляем в список to. C помощью LINQ это очень легко делается.
Пока создавалась модель игрового приложения, солнце давно пересекло линию горизонта. С туманом в глазах и некоторым удовольствием от того, что проект всё-таки начат, я отправился спать.

День второй


Скачав набор картинок игровых карт, я принялся создавать вид. Создав подпроект View, первым делом я добавил UserControl CardView, в котором был один только компонент - Image. А больше ничего и не надо для отображения карты пользователю, ведь так?
Немного отвлекусь и объясню, зачем я сделал отдельные классы для карты и мм... карты. Если вы знаете, что такое Модель-Вид, то смело пропускайте следующий абзац.
"Зачем всё усложнять? Зачем создавать отдельно кучу классов, когда всё может поместиться и в одном?", - спросите вы. Вот и я спрашиваю, зачем усложнять? Зачем всё лепить в один класс, если можно разделить на отдельные куски? Возьмём нашу злосчастную карту. Она имеет определённые свойства в игре: тузы можно класть в стопки, шестёрки можно класть на семёрки (не всегда правда) и так далее. Это взаимодействие называется моделью. Нам незачем знать какая картинка нарисована на пиковой даме, может там вообще нет рисунка. Чтобы определить свойства карты нам нужно знать только её значение и масть. А уж тем, какая картинка нарисована на карте занимается вид. Может там мотоцикл нарисован вместо бубнового вальта или кто-то ручкой подрисовал рожицу королю, всё равно это будут бубновый валет и король. Представление (оно же вид) отвечает только за то, что видят другие. Модель - за то, какими свойствами обладает объект и какие действия он может выполнять. Разделяя объекты на модель и вид, можно существенно облегчить множество вещей, таких, например, как изменение внешнего вида карт (вид) или изменение логики их работы (модель). Причём все эти изменения будут независимы друг от друга.
Вслед за видом карты я создал отображение таблицы TableauView. Если с картой всё понятно - там только один Image, то с таблицей чуть-чуть посложнее, так как в ней должно отображаться несколько карт со сдвигом. В XAML просто так не пропишешь нужную последовательность, поэтому нужно карты добавлять вручную. Как в модели таблица содержит список карт, так и в представлении TableauView содержит список CardView. Добавлять каждый CardView лучше всего на Canvas, так как там можно с лёгкостью указать нужную координату и вывести карты со сдвигом:
private void AddCard(CardView cardView, Card card, int index) {
    cardView.Card = card;
    Canvas.SetTop(cardView, cardSpace * index);
    Panel.SetZIndex(cardView, 1 + index);
    rootView.Children.Add(cardView); 
    cardViews.Add(cardView);
}

Похожим способом были созданы представления для стопок и запаса. Только там уже можно было схитрить и выводить только верхнюю карту, потому что в игре никакой роли от сдвига в этих колодах не будет.
Следующей задачей было обновление экрана. Допустим мы добавили карту в таблицу, нужно как-то оповестить об этом окно приложения. Вот тут-то я и пожалел, что в своё время не изучил MVVM и биндинг, ибо с их помощью можно без труда обновлять тёмные уголки экрана. Но ничего не поделаешь, придётся вручную влезать в эти тёмные уголки и обновлять UserControl'ы. Если бы использовался биндинг, то при любой операции со списком cardViews rootView.Children обновлялись бы автоматически, а так нам придётся делать эту вручную.
В принципе это не сложно: просматриваем весь список карт и, пока количество Children'ов у UserControl'а совпадает с количеством карт - обновляем их картинки. Если же оказывается, что список больше, чем количество Children'ов, то добавляем их (rootView.Children.Add()), если наоборот - удаляем. Вот пример:
public void RefreshView() {
    List cards = tableau.GetList();
    for (int i = 0; i < cards.Count; i++) {
        var card = cards[i];

        CardView cardView;
        if (i < cardViews.Count) {
            cardView = cardViews[i];
            cardView.Card = card;
        } else {
            cardView = new CardView();
            AddCard(cardView, card, i);
        }
    }
    // Удаляем лишнее
    for (int i = cardViews.Count - 1; i >= cards.Count; i--) {
        CardView v = cardViews[i];
        rootView.Children.Remove(v);
        cardViews.Remove(v);
    }
}
Так придётся делать для всех созданных UserControl'ов.

Итак, всё готово, осталось, как и в модели, создать главный класс GameView, который управляет всеми колодами карт и игровым процессом. Я решил не изобретать что-либо кардинально новое, а просто продублировал основные методы класса GameTable. Идея в том, что при какой-то операции мы будем вызывать одноимённый метод в GameTable. Таким образом, всё что делается в представлении, будет отражаться и на модели.
Наглядный пример - раздача карт из запаса. Мы вызываем такой же метод в модели, а потом обновляем компоненты, которые могли измениться, чтобы изменения модели отразились на представлении:
public void HandOutFromStock() {
    table.HandOutFromStock();
    stockView.RefreshView();
    for (int i = 0; i < GameTable.TABLEAUS; i++) {
        tableauViews[i].RefreshView();
    }
}
На этом я решил закруглиться, потому что на следующий день у меня должен был быть экзамен.

День третий


Проснувшись в 8 утра, первой моей мыслью было: "Надо кодить!". Через час работа над игрой шла полным ходом, я придумывал, как сделать перемещение карт. Алгоритм по-сути не сложен - прибавляем к координате карты разницу текущей и предыдущей позиции курсора. На просторах Интернета был найден и чуточку переделан класс, красиво реализующий эту функцию через RenderTransform.
Каждый (а может и не каждый) компонент в WPF можно трансформировать как угодно - менять координаты (Translate), поворачивать (Rotate) и т.д. Плюс такого метода в том, что вызвав всего одну строчку view.RenderTransform = null можно вернуть на место компонент.
Остаётся теперь написать проверку, куда была перемещена карта, но для этого нужно знать границы областей карт:
public static Rect GetBoundingRect(Visual view, Visual relativeTo = null) {
    if (relativeTo == null) relativeTo = GameView.Instance.GetRootView();
    Vector relativeOffset = new Point() - relativeTo.PointToScreen(new Point());
    Rect result = new Rect(view.PointToScreen(new Point()) + relativeOffset, VisualTreeHelper.GetDescendantBounds(view).Size);
    return result;
}
Чтобы узнать границы области нескольких карт, можно сделать так:
Rect result = GetBoundingRect(views[0]);
for (int i = 1; i < views.Count; i++)
    result.Union(GetBoundingRect(views[i]));
Вот теперь делаем проверку при помощи метода IntersectsWith:
public void DragCompleted(TableauView tableauView, CardView cardView) {
    Rect cardRect = GetCardRect(cardView);
    // Просматриваем перемещение по таблицам.
    for (int i = 0; i < GameTable.TABLEAUS; i++) {
        TableauView view = tableauViews[i];
        if (view.Equals(tableauView)) continue;

        Rect rect = view.Bounds;
        if (cardRect.IntersectsWith(rect))  {
            System.Diagnostics.Debug.Print("Tableau: {0}", i);
            return;
        }
    }
    // Вернуть карту на место.
    cardView.RenderTransform = null;
}

Чтобы границы перемещаемой карты не перекрывали сразу несколько областей, можно взять от неё лишь маленький кусок - в центре по-горизонтали и чуть ближе к верху:
private Rect GetCardRect(CardView cardView) {
    Rect cardRect = Util.GetBoundingRect(cardView);
    Point cardPoint = new Point {
        X = cardRect.Left + cardRect.Width / 2,
        Y = cardRect.Top + cardRect.Height / 3
    };
    return new Rect(cardPoint, new Size(1, 1));
}
Аналогичным образом делается проверка перемещения карты в результирующую стопку:

  1. Берём некоторую границу перемещаемой карты;
  2. Просматриваем все имеющиеся стопки (их у меня 8);
  3. Рассчитываем ограничивающую область каждой стопки;
  4. Проверяем методом IntersectsWith пересекаются ли две области - карты и стопки;
  5. Если пересекаются, значит карта перемещена в текущую просматриваемую в цикле стопку (foundationView[i]);
  6. Если прошли весь цикл, но пересечений так и не нашли - сбрасываем трансформацию карты, то есть возвращаем её на место.
Для закрепления материала, приведу полный метод DragCompleted:

public void DragCompleted(TableauView tableauView, CardView cardView) {
    Rect cardRect = GetCardRect(cardView);
    // Просматриваем перемещение по стопкам.
    for (int i = 0; i < GameTable.FOUNDATIONS * 2; i++) {
        FoundationView view = foundationViews[i];
        Rect rect = view.Bounds;
        if (cardRect.IntersectsWith(rect)) {
            if (!view.Foundation.IsCorrectMove(cardView.Card)) {
                CancelMove(cardView);
                return;
            }
            // Добавляем карту в стопку.
            table.MoveCard(cardView.Card, tableauView.Tableau, view.Foundation);
            tableauView.RefreshView();
            view.RefreshView();
            return;
        }
    }
    // Просматриваем перемещение по таблицам.
    for (int i = 0; i < GameTable.TABLEAUS; i++) {
        TableauView view = tableauViews[i];
        if (view.Equals(tableauView)) continue;

        Rect rect = view.Bounds;
        if (cardRect.IntersectsWith(rect)) {
            if (!view.Tableau.IsCorrectMove(cardView.Card)) {
                CancelMove(cardView);
                return;
            }
            // Переносим карту в другую таблицу.
            table.MoveCard(cardView.Card, tableauView.Tableau, view.Tableau);
            tableauView.RefreshView();
            view.RefreshView();
            return;
        }
    }
    CancelMove(cardView);
}
Здесь, кстати, видны преимущества отделения модели от вида - мы не перемешиваем логику представления (пересекла ли карта стопку, вышла ли карта за пределы окна и т.д.) и логику модели (корректен ли ход, есть ли еще карты в стопке и т.д.).

В игру уже можно кое-как играть, но остались не реализованными две важные функции: возможность перемещения нескольких карт и отмена/повтор действий. Первое я никак не понимал, как сделать, поэтому начал со второго.

Представим, что перед нами лежат три кубика: 'К', 'У', 'Б'. Добавим один кубик 'А' и запишем на чистом листе (+А). Как нам вернуться на одно действие назад? Очевидно выполнив обратное от (+А) действие - убрать кубик 'A'. А как повторить? Да всё также (+A). Обратите внимание, нам не нужно хранить начальные данные, мы просто храним переходы.
Всё также и с картами. Мы знаем откуда и куда перенесли карту и этого уже вполне достаточно для реализации истории перемещений. Определим структуру, которая будет хранить в полной мере одно наше перемещение:
public struct Move {
    // Карты
    public List<Card> Cards;

    // Тип перемещения
    public MoveType Type;

    // Откуда перемещено
    public Tableau FromTableau;

    // Куда перемещено
    public Foundation ToFoundation;
    public Tableau ToTableau;
}
Теперь, в зависимости от того, откуда и куда мы перемещаем, мы должны заполнить структуру и добавить её в список. Пример перемещения между таблицами:
public static void Move(Card card, Tableau from, Tableau to) {
    Move move = new Move() {
        Card = card,
        FromTableau = from,
        ToTableau = to,
        Type = MoveType.TO_TABLEAU
    };
    AddToHistory(move);
}

private static void AddToHistory(Move move) {
    if (moveIndex != moves.Count) {
        int removeLength = moves.Count - moveIndex;
        moves.RemoveRange(moveIndex, removeLength);
    }
    moves.Add(move);
    moveIndex++;
}
moveIndex здесь указывает на текущий элемент истории переходов. Если мы отменяем действия, то он уменьшается, если повторяем - увеличивается. Удалять элементы списка истории следует не при отмене/повторе, а именно при добавлении и только в том случае, если индекс не совпадает с размером списка истории.
Вот как делать обратную операции. Допустим, мы добавили карту из таблицы 2 в стопку 3, при отмене нам нужно сделать наоборот:
move.ToFoundation.GetList().Remove(move.Card); // удаляем из стопки 3
move.FromTableau.AddCardBySystem(move.Card); // добавляем в таблицу 2.
Надеюсь, останавливаться на этом подробнее не нужно, потому что мы переходим к не менее интересной теме.

Вдоволь наигравшись с историей перемещений, я стал думать, как бы сделать перемещение нескольких карт. Видимо мозги достаточно активно работали после реализации истории перемещений, потому что не прошло и пять минут, как я понял, как это сделать.
Суть такова: при нажатии на любую карту из таблицы мы просматриваем все карты, начиная с верхней и получаем последовательность, которую по правилам можно двигать. В моём случае это карты с чередующимися цветами по убыванию (а если смотреть с верхней карты, то по возрастанию). Затем проверяем, входит ли выбранная карта в эту последовательность. Если входит, то отделяем карты от самой верхней до выбранной и перекидываем их на новый UserControl, который можно двигать. Если не входит - ничего не делаем.
При перемещении учитывать будем не верхнюю карту этой последовательности, а самую нижнюю (ту, за которую мы брались). Таким образом, в модели нам практически ничего менять не придётся - IsCorrectMove всё также будет принимать одну карту, единственное, нужно сделать добавление списка карт, что вообще решается в одну строчку list.AddRange(sublist);

Вот так получаем список доступных для перемещения карт:
public List<Card> GetDraggableTopCards() {
    if (base.Cards.Count == 0) return null;

    var cards = new List<Card>();
    Card top = base.Cards[base.Cards.Count - 1];
    cards.Add(top);
    if (base.Cards.Count == 1) return cards;

    for (int i = base.Cards.Count - 2; i >= 0; i--) {
        Card beforeTop = base.Cards[i];
        if (IsCorrectMove(top, beforeTop)) {
            cards.Add(beforeTop);
        } else break;
        top = beforeTop;
    }

    var rev = cards.Reverse<Card>();

    return rev.ToList();
}
Поскольку мы просматриваем от конца к началу, то нужно обратить последовательность в списке методом Reverse().

Вот так выносим карты в отдельный компонент DraggableCards:
private void cardView_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) {
    // При нажатии левой кнопки мыши проверяем, можем ли мы переместить карты
    // от выбранной до нижней. Если можем - создаём из них DraggableCards и перемещаем их.
    var view = (CardView) sender;
    List<Card> draggable = Tableau.GetDraggableTopCards();
    for (int i = 0; i < draggable.Count; i++) {
        var card = draggable[i];
        if (view.Card.Equals(card)) {
            // Собираем карты в новый компонент.
            var draggableCards = new DraggableCards();
            draggableCards.Cards = draggable.GetRange(i, draggable.Count - i);
            // Карты в таблице скрываем.
            foreach (var cardView in cardViews) {
                foreach (var _card in draggableCards.Cards) {
                    if (cardView.Card.Equals(_card)) {
                        cardView.Visibility = Visibility.Hidden;
                    }
                }
            }
            // Добавляем новый компонент на форму.
            Canvas.SetTop(draggableCards, Canvas.GetTop(view));
            Canvas.SetLeft(draggableCards, Canvas.GetLeft(view));
            Panel.SetZIndex(draggableCards, 200);
            rootView.Children.Add(draggableCards);
            DragHelper.Drag(draggableCards, OnDragCompleted, e.GetPosition(null));
            return;
        }
    }
}
Чтобы иметь возможность отменить перемещение, пришлось на время скрывать существующие карты в списке. Дело в том, что перемещая карты в DraggableCards установка dragCards.RenderTransform = null вернёт сам UserControl на первоначальное место, но никак не сами карты, поэтому приходится вот так изощряться. Главное - работает ;)
Еще пришлось переделать метод Drag в DragHelper. Дело в том, что при нажатии кнопки мыши мы создаём последовательность карт, и в перемещении на нажатие кнопки мыши повешен старт перемещения. Но если уже сработал MouseDown, то второй раз он не сработает, пока не выполнится событие MouseUp, а это будет означать бросание карт. Поэтому пришлось вырезать создание события MouseDown в методе Drag так, чтобы при его вызове сразу инициировалось перемещение. Ведь раньше оно начиналось при нажатием кнопки мыши, а сейчас мы вызываем этот метод когда уже активно событие.

Реализовав последние две на первый взгляд сложные функции, меня уже было не остановить и я приступил к завершающему этапу - проверке окончания игры.
Игра считается завершенной, когда заполнены все результирующие стопки. То есть в каждой из них по 13 карт:
private void CheckGameOver() {
    for (int j = 0; j < GameTable.FOUNDATIONS * 2; j++) {
        if (!foundationViews[j].Foundation.IsFinished())
            return; // играем дальше
    }
    // игра завершена
}
Не помешало бы добавить автоматическое добавление собранных 13 карт в правые стопки:
private void CheckAutoMovesToRightFoundation() {
    for (int i = 0; i < GameTable.TABLEAUS; i++) {
        TableauView view = tableauViews[i];
        if (!view.Tableau.CheckFillKingToAce()) continue;
        // Найдена последовательность от короля до туза.
        // Ищем, куда её переместить.
        for (int j = 0; j < GameTable.FOUNDATIONS; j++) {
            Foundation fn = table.GetFoundation(j, false);
            if (fn.GetTopCard() == null) {
                table.MoveCards(view.Tableau.GetDraggableTopCards(), view.Tableau, fn);
                break;
            }
        }
        RefreshView();
        CheckGameOver();
    }
}
CheckFillKingToAce() работает также как и GetDraggableTopCards() только специализировано на последовательности из 13 карт.

Вот так прошел третий день разработки.
На четвёртый день я добавлял статистику, меню и прочие красивости. Описывать это не вижу смысла.

В итоге получилось что-то такое :)



Исходный код доступен на GitHub: Rouge-et-Noir
Скачать игру можно здесь.

Комментариев нет:

Отправить комментарий