Pull to refresh

Alan.Platform Tutorial (Part 2)

Reading time 14 min
Views 1.1K
В первой части мы начали моделирование игры в шашки с помощью Alan.Platform. Мы создали библиотеку элементов, к которой добавили один элемент, оператор, управляющий расположением шашек. Также с помощью конструктора мы создали две шашки, расположенные по углам платформы. Все это можно было лицезреть в консоли, в виде текста, который был любезно составлен ObjectDumper'ом.

Как бы ни был хорош ObjectDumper — нашему мозгу трудно разглядеть доску для игры в шашки среди пар ключ-значение. Поэтому нужно создать графическое представление для модели. Этим мы в ближайшее время и займемся.

От графического интерфейса нам нужно две вещи. Первая — возможность увидеть в окне объекты созданного мира, узнать их свойства. Вторая — возможность подействовать на эти объекты, изменить их свойста. Так уж вышло, что от модели организма требуются те же самые возможности — она должна уметь «почувствовать» свойства окружающих объектов, а также уметь совершать над ними действия, изменяющие эти свойства.

Это значит, что создание графического интерфейса будет похоже на моделирование организма. И не просто похоже — будут использоваться одни и те же классы и принципы. Проще всего это объяснить с помощью следующей диаграммы:

Стрелки указывают, в каком направлении движется информация. Как я и говорил в первой части — информация ходит по кругу. Оператор и мозг взаимодействуют друг с другом. При этом изменяются состояния мира (Properties) и мозга (Memory).

Действия и сенсоры в данном случае являются посредниками этого взаимодействия, медиаторами. Их основная задача — преобразование информации из формы, понятной одной стороне, в форму, понятную другой. Для оператора — это наборы свойств, а для мозга — массив значений.

Действия, сенсоры и мозг вместе являются одной сущностью — организмом. В Alan.Platform организм реализован как производный от компонента класс — клиент. К нему добавилась возможность содержать набор сенсоров, действий и мозг.

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

Итак, из каких частей будет состоять наш организм? Нужен один сенсор, который будет подключен к созданному ранее оператору CellBoard и который сможет видеть расположение шашек и преобразовывать эту информацию во что-то, пригодное для отображения в окне. Также нужно одно действие, которое будет передвигать шашки по платформе. И наконец нужен мозг, который будет управлять всем этим. Он будет отвчать за отрисовку шашек и обработку действий пользователя.

Звучит довольно сложно, но подходящий мозг уже есть в составе Alan.Platform. Он использует WPF для отрисовки объектов. Более того — он сам унаследован от FrameworkElement, так что его можно помещать прямо на окно.

Tutorial[«Part 2»]


Вооружившить теорией, можно плавно переходить к практической части. Скопируем в созданное ранее решение проекты Platform.Explorer и ElementsLibSample из Alan.Platform. Сделаем Platform.Explorer запускаемым по умолчанию проектом и добавим к нему ссылку на созданную ранее библиотеку элементов, содержащую CellBoard.

Начнем с сенсора. Добавим к нашей библиотеке элементов новый класс UISensor.
using System;
using System.Collections.Generic;
using Platform.Core.Concrete;
using Platform.Core.Elements;

namespace Checkers
{
  [AssociatedOperator("Checkers.CellBoard")]
  [ChannelsCount(300)]
  public class UISensor : Sensor
  {
    public override void Update(IEnumerable<PropertySet> elements)
    {
      throw new NotImplementedException();
    }
    
    public override void Transmit()
    {
      throw new NotImplementedException();
    }
  }
}
AssociatedOperator указывает, с каким оператором умеет работать сенсор. В конструктор передается полное имя типа оператора. Каждый сенсор и действие должны иметь такой атрибут.

В обычной ситуации ChannelsCount указывает количество значений, которое передается в мозг. Но наш интерфейсный мозг не совсем обычный и не принимает значений. Чтобы выжать хоть какую-то пользу из атрибута, в его конструктор передан масштаб. По соглашению, все значения свойств находятся в пределах от 0 до 1. Таким образом наша доска в клеточку имеет размеры 1х1. Используя масштаб, доска будет иметь размер 300х300 юнитов при отрисовке в окне.

Далее у нас идут реализации абстрактных методов. Метод Update объявлен в абстрактном классе Mediator, от которого унаследованы Sensor и Action. Он помогает реализовать «поле зрения». Для сенсора — это набор объектов, свойства которых он может увидеть. Для действия — набор объектов, свойства которых оно может изменить. Эти объекты хранятся в защищенном поле VisibleElements. Так как нам нужно видеть все объекты, то реализация метода Update примет вид:
public override void Update(IEnumerable<PropertySet> elements)
{
  this.VisibleElements = elements;
}
Этот метод вызывается оператором каждый раз, когда поле зрения может измениться, т. е. тогда, когда перемещается один из объектов, а также почти сразу после запуска программы. В метод передаются все наборы свойств, которые есть у оператора, чтобы сенсор выбрал из них те, которые он «видит».

Метод Transmit объявлен в классе Sensor. Его задача — передать в мозг информацию о свойствах объектов, предварительно преобразовав ее в понятную мозгу форму. Этот метод вызывается оператором, когда значения свойств изменяются. Поэтому у мозга всегда актуальная информация о состоянии мира.

Наш мозг ожидает получить информацию о том, как следует отрисовывать объекты. Этот механизм использует RenderInstructions — простую обертку для DrawingVisual. В мозге определены три метода, которые принимают инструкции:
public void SetShape(int id, Shapes shape, double height, double width);
public void SetBrush(int id, Brush brush);
public void SetTransform(int id, Transform transform);
Id — это идентификатор компонента, для которого указывается инструкция. В мозге с каждым id связан объект RenderInstructions. Вызывая один из этих методов, мы меняем свойства этого объекта и заставляем его перерисоваться. Для этого внутри RenderInstructions вызывается метод RenderOpen() соответствующего объекта DrawingVisual. Сотавляется новый контекст для отрисовки объекта, а затем WPF сама позаботится о том, чтобы обновить содержимое окна.

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

С аргументами, я думаю, должно быть все понятно. Shapes — это перечислимый тип, который пока содержит только две формы — эллипс и прямоугольник. Размеры есть, заливка есть, но нет самих координат. Не хотелось бы при каждом перемещении шашки открывать и закрывать DrawingContext. Поэтому перемещения будут реализованы с помощью TranslateTransform. Изначально центры всех шашек будут находиться в точке (0,0), откуда они будут транслироваться по месту назначения. Для смещения шашки на экране нужно будет просто изменить значения свойств X и Y соответствующего объекта TranslateTransform. Так как эти свойства являются DependencyProperty, для их изменения можно использовать анимацию.

С отрисовкой вроде бы разобрались, можно приступать к ее реализации. В методе Transmit мы дожны будем изменять свойства TranslateTransform. Но прежде чем их изменять, нужно добавить эту трансформацию к RenderInstructions. Туда же нужно добавить и все остальные инструкции. Это нужно сделать один раз после запуска программы.

Наиболее подходящим для этого местом является метод ConnectTo, объявленный в интерфейсе IConnectable, который реализуют все элементы. Он вызывается сразу после запуска программы и помогает связывать элементы. Поэтому он является удачным местом для инициализации.

Добавляем к нашей библиотеке элементов ссылки на PresentationCore, PresentationFramework, WindowsBase и проект ElementsLibSample.
BaseUIBrain brain;
int scale;

public override void ConnectTo(Component parent)
{
  base.ConnectTo(parent);
  
  this.brain = this.ConnectedBrain as BaseUIBrain;
  this.scale = Sensor.GetChannelsCount(this.GetType());
  this.brain.Scale = scale; // Меняет размер окна.
  var checkerBrush = Brushes.BurlyWood;
  double diameter = 0.12 * scale; // Диаметр шашки.
  
  // К этому моменту метод Update уже вызван, так что поле
  // VisibleElements проинициализировано.
  foreach (var checker in this.VisibleElements)
  {
    // Координаты центра шашек.
    double x = checker["X"].Value * scale;
    double y = checker["Y"].Value * scale;
    
    var translate = new TranslateTransform(x, y);
    
    brain.SetShape(checker.Id, Shapes.Ellipse, diameter, diameter);
    brain.SetBrush(checker.Id, checkerBrush);
    brain.SetTransform(checker.Id, translate);
  }
}
Этого уже достаточно, чтобы увидеть на экране заветные кружочки. Platform.Explorer хранит конфигурацию модели в файле world.xml. Откроем его и заменим его содержимое на следующее:
<?xml version="1.0" ?>
<component xmlns="http://alan.codeplex.com/constructor/world">
  <operator name="Checkers.CellBoard" />
  <component>
    <propertySet name="Checker" operator="Checkers.CellBoard">
      <property name="X" value="0.0625" />
      <property name="Y" value="0.6875" />
    </propertySet>
  </component>
  <component>
    <propertySet name="Checker" operator="Checkers.CellBoard">
      <property name="X" value="0.0625" />
      <property name="Y" value="0.9375" />
    </propertySet>
  </component>
  <client>
    <propertySet name="Checker" operator="Checkers.CellBoard">
      <property name="X" value="0.1875" />
      <property name="Y" value="0.8125" />
    </propertySet>
    <sensor name="Checkers.UISensor" />
    <brain name="ElementsLibSample.UIElements.BaseUIBrain" />
  </client>
</component>
Таким образом мы создаем три шашки, одной из которых является наш организм-интерфейс. Теперь можно запускать. Вот, что должно получиться в итоге:

BaseUIBrain поддерживает выделение объектов. Если нажать на любую из шашек, внизу можно увидеть значения всех ее свойств (значения округлены). Немного подправив XAML и world.xml, можно добиться следующего результата:

Итак, сенсор есть, мозг есть. Осталось действие. Добавим новый класс MoveChecker к нашей библиотеке элементов.
using System;
using System.Linq;
using System.Collections.Generic;
using Platform.Core.Concrete;

namespace Checkers
{
  [AssociatedOperator("Checkers.CellBoard")]
  [ChannelsCount(3)]
  public class MoveChecker : Platform.Core.Elements.Action
  {
    public override void Update(IEnumerable<PropertySet> elements)
    {
      this.VisibleElements = elements;
    }
    
    public override void DoAction(params double[] args)
    {
      throw new NotImplementedException();
    }
  }
}
Класс Action очень похож на Sensor, только вместо метода Transmit у него есть DoAction, который принимает от мозга массив значений double. Если сенсоры брали свойства объектов и из них составляли набор данных, то у действий обратная задача — они должны, используя набор данных, найти нужные объекты и изменить их свойства.

Для нашего действия будет достаточно трех аргументов. В первом будет id шашки, которую нужно переместить, во втором — значение, на которое нужно изменить координату «X», а в третьем — значение, на которое нужно изменить координату «Y».

Та часть Alan.Platform, которая отвечает за изменение свойств, тесно связана с внутренним временем платформы, о котором я еще не упоминал. Внутреннее время реализовано в виде тактов. Центром управления временем является статический класс Platform.Core.Concrete.Time.

Идея заключается в том, что существуют некоторые объекты, состояние которых может зависеть от времени. Эти объекты регистрируются в классе Time. Затем, когда пользователь вызывает метод Time.Tick(), аналогичный метод вызывается у всех зарегистрированных объектов. Разработчик может переопределить этот метод. Эти объекты называются TimeObject, и PropertySet является одним из них.

PropertySet позволяет планировать изменения свойств следующим образом:
propertySet["PropertyName"][ticks] = delta;
Где ticks — количество тактов, через которое к свойству следует добавить значение, а delta — это и есть то самое значение. Например:
checker["Y"][1] = 0.125;
Эта строчка означает, что на следующий такт к свойству «Y» нужно добавить 0.125. Минимальное число тактов, на которое можно запланировать изменение — 1, максимальное — 10 (пока что). Т. е. мгновенно изменить значение нельзя.

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

Теперь можно приступать к реализации DoAction:
public override void DoAction(params double[] args)
{
  var checker = this.VisibleElements.First(x => x.Id == args[0]);
  
  checker["X"][1] = args[1];
  checker["Y"][1] = args[2];
}
На этом наше действие готово и его можно добавлять в world.xml:
...
  </propertySet>
  <action name="Checkers.MoveChecker" />
  <sensor name="Checkers.UISensor" />
  <brain name="ElementsLibSample.UIElements.BaseUIBrain" />
</client>
...
Если сейчас запустить Platform.Explorer, то никаких изменений мы не увидим, так как метод DoAction у нас нигде не вызывается. Исправляем это недоразумение. Для этого нужно к библиотеке элементов добавить новый класс UIBrain, производный от BaseUIBrain, который мы до этого использовали.
using ElementsLibSample.UIElements;

namespace Checkers
{
  public class UIBrain : BaseUIBrain
  {
    
  }
}
Нам нужно, чтобы в ответ на какое-то действие пользователя UIBrain вызывал метод DoAction с нужными параметрами. Проще всего повесить это на событие KeyDown — нажатие клавиши на клавиатуре. Для начала добавим обработчик для этого события. Это можно сделать в методе ConnectTo:
public override void ConnectTo(Decorator parent)
{
  base.ConnectTo(parent);
  
  this.KeyDown += UIBrain_KeyDown;
}
Этот хитрый метод ConnectTo определен в BaseUIBrain, а не в IConnectable. У BaseUIBrain два родителя. Один — это клиент из дерева модели мира, а другой — Border из мира WPF. Так или иначе инициализацию UIBrain можно провести в этом методе.

Обработчик UIBrain_KeyDown очень простой:
void UIBrain_KeyDown(object sender, KeyEventArgs e)
{
  if (this.selectedId != 0)
  {
    var moveChecker = this.actions["Checkers.MoveChecker"];
    
    switch(e.Key)
    {
      case Key.Q:
        moveChecker.DoAction(selectedId, -0.125, -0.125);
        break;
      case Key.W:
        moveChecker.DoAction(selectedId, 0.125, -0.125);
        break;
      case Key.S:
        moveChecker.DoAction(selectedId, 0.125, 0.125);
        break;
      case Key.A:
        moveChecker.DoAction(selectedId, -0.125, 0.125);
        break;
    }
    Time.Tick();
    OnChanged(selectedId);
  }
}
Вначале мы проверяем, выделена ли какая-нибудь шашка. Если да, то selectedId будет содержать ее индекс. Далее находим наше действие. Затем анализируем, какая из клавиш была нажата. Если это 'Q' — перемещаем выделенную шашку вверх-влево, если это 'W' — вверх-вправо, 'S' — вниз-вправо, 'A' — вниз-влево. После этого отсчитываем один такт.

Метод Tick вернет управление только тогда, когда все его подписчики обработают это событие. Т. е. тогда, когда изменения свойств, запланированные на следующий такт, будут применены. В самом конце вызывается метод OnChanged, который обновляет информацию о свойствах внизу окна. Такт будет отсчитан при нажатии на любую из клавиш.

UIBrain готов. Осталось только указать его в world.xml вместо BaseUIBrain и можно запускать… Запуск не удается, так как мы забыли реализовать CellBoard.ValidatePropertySet. Этот метод вызывается сразу после изменения значений свойств в наборе. Именно в нем реализуются законы мира. Вот, как выглядит его объявление в классе Operator:
public abstract bool ValidatePropertySet(PropertySet ps);
Единственный параметр — это набор свойств, у которого нужно провереть новое состояние и вернуть результат — является ли это состояние допустимым. Если нет, то произойдет откат к предыдущему состоянию, т. е. запланированные изменения свойств не будут применены. Так как у шашек довольно много законов, пока поставим на их месте заглушку «return true» и попробуем запустить программу снова…

Повторный запуск не удается, так как мы забыли реализовать еще и UISensor.Transmit. Здесь мы должны изменить свойства объектов TranslateTransform.
public override void Transmit()
{
  foreach (var element in VisibleElements)
  {
    var translate = brain.GetTransform<TranslateTransform>(
      element.Id);
    translate.X = element["X"].Value * scale;
    translate.Y = element["Y"].Value * scale;
  }
}
Ну теперь точно запустится! Постучав по дереву, нажимаем Run… Уррраа! Если вас не смущает то, что все шашки одного цвета — можете сыграть патрию-другую.

Конечно, есть некоторые недочеты. Например, нельзя удалить сбитую шашку, можно только переместить ее за пределы окна. Удаление и добавление объектов пока не поддерживаются в Alan.Platform. Шашками можно ходить как вперед, так и назад и даже ставить одну на другую. В принципе это исправить можно — достаточно добавить соответствующий код в CellBoard.ValidatePropertySet, который забракует подобные состояния шашек. Также можно добавить цвета шашкам, а еще — добавить состояние «дамка-не дамка». Все это можно сделать, добавив еще один оператор и подключив к нему свои сенсор и действие.

На этом туториал заканчивается. В нем мы создали модель мира, населенного 24-мя шашками, одной из которых является организм-интерфейс. Архив с конечным результатом можно скачать здесь.

В целом модель не обязательно должна быть привязана к WPF. Вполне можно по аналогии создать новый Command Line Interface в виде организма и добавить его к остальным шашкам. Можно вообще не добавлять организмы в модель, но тогда ее состояние никто не увидит и никто не изменит.

В самом конце хотелось бы ответить на вопрос — что же такое Alan.Platform? Это то, что позволяет вам забыть о служебном коде, о создании и связывании объектов, о том, чтобы поддерживать их в актуальном состоянии и т. п. Можно просто сесть и заняться моделированием.

P. S. Может показаться, что подобная идея создания объектов во время выполнения избыточная, сложная и неудобная. Куда проще и понятнее было бы создавать строго типизированные объекты со свойствами на подобие того, как это деляется в ORM системах. С одной стороны это упростит создание объектов, так как можно будет использовать все прелести ООП. С другой — это усложнит создание законов мира и взаимодействие мозга с объектами.
  • Аналог оператора будет невозможно создать, даже с использованием Reflection API.
  • Законы мира придется встраивать в сами объекты.
  • Дерево наследования будет постоянно разрастаться.
  • Композиция может усложнить процесс создания объектов.
  • Аналогам сенсоров и действий придется либо знать обо всех типах объектов, либо использовать Reflection API.
В самом начале я пробовал построить подобную систему и столкнулся с этими проблемами. Расширять и усложнять мир, смоделированный подобным образом, очень сложно. Реализовывать сенсоры и действия также очень сложно. Примерно тогда же стало понятно, что сенсорам и действиям на самом деле не нужны объекты — им нужны лишь свойства. Отсюда и родилась идея с операторами. Что было дальше, вы уже знаете.

Желающих поучаствовать в проекте или использовать его для создания чего-нибудь просьба обращаться в ЛС или стучаться в jabber:
openminded@xdsl.by
Tags:
Hubs:
+3
Comments 0
Comments Leave a comment

Articles