Как стать автором
Обновить

Программирование Magic: the Gathering — §1 Мана

Время на прочтение19 мин
Количество просмотров4.2K

Хочется начать посты про программирование Magic: the Gathering (M:tG), и начнем мы пожалу с самого простого – с концепции «маны». Мана – это то, чем оплачиваются все заклинания. Несмотря на то, что с виду маны всего 5 типов, на самом деле все чуть-чуть сложнее. Давайте попробуем разобраться.



Во-первых маны вовсе не 5 типов. Даже если отбросить «сдвоенную» ману (когда можно платить либо одной, либо другой), то все равно есть еще «бесцветная» мана, за которую покупаются артефакты, и которая фигурирует в стоимости многих (большинства) заклинаний. Также – если мы говорим о «цене» а не об оплате, фигурирует мана Х, т.е. ситуация когда цена регламентирована правилами.

Рассмотрим несколько примеров.

Цвета обыкновенные


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



class Mana
{
  ⋮
  public int Blue { get; set; }
  public bool IsBlue { get { return Blue > 0; } }
  // и так далее
}

Пока мы играем с идеей того, что стоимость и наличие маны в пуле могут содержаться в одной сущности, можно пофантазировать еще чуть-чуть. Например, как получить представление маны в текстовой строке (например WUBRG для Sliver Legion)? Примерно вот так:

public string ShortString
{
  get
  {
    StringBuilder sb = new StringBuilder();
    if (Colorless > 0) sb.Append(Colorless);
    if (Red > 0) sb.Append('R'.Repeat(Red));
    if (Green > 0) sb.Append('G'.Repeat(Green));
    if (Blue > 0) sb.Append('U'.Repeat(Blue));
    if (White > 0) sb.Append('W'.Repeat(White));
    if (Black > 0) sb.Append('B'.Repeat(Black));
    if (HasX) sb.Append("X");
    return sb.ToString();
  }
}

Это я так иллюстрирую слабость модели. Если бы мы не знали что есть сдвоенная мана (а мы-то знаем), то последующие изменения вызвали бы архитектурный апокалипсис в нашей сущности и всем, с чем она взаимодействовала. Это во-первых.

Во-вторых, писать одно и то же 5+ раз – это плохо. Представьте что вы реализуете метод оплаты определенной мана-стоимости из пула. Если следовать все тому же подходу, вам наверное придется написать что-то подобное:

public void PayFor(Mana cost)
{
  if (cost.Red > 0) Red -= cost.Red;
  if (cost.Blue > 0) Blue -= cost.Blue;
  if (cost.Green > 0) Green -= cost.Green;
  if (cost.Black > 0) Black -= cost.Black;
  if (cost.White > 0) White -= cost.White;
  int remaining = cost.Colorless;
  while (remaining > 0)
  {
    if (Red > 0) { --Red; --remaining; continue; }
    if (Blue > 0) { --Blue; --remaining; continue; }
    if (Black > 0) { --Black; --remaining; continue; }
    if (Green > 0) { --Green; --remaining; continue; }
    if (White > 0) { --White; --remaining; continue; }
    if (Colorless > 0) { --Colorless; --remaining; continue; }
    Debug.Fail("Should not be here");
  }
}

Количество повторений не «зашкаливает», но безусловно раздражает. Напомню, что в C# нет макросов.

Бесцветная мана


Бесцветная мана – это первый намек на то, что каждый тип маны тянет за собой доменно-специфичную логику, которую в принципе сложно предугадать. Например, карта справа – это типичный пример киндер-сюрприза в работе с таким негибким доменом как M:tG. Тем не менее, даже используя все ту же модель (в C#), можно получить несколько дополнительных методов. Например, вот как выглядит свойство «конвертированной стоимости»:



public int ConvertedManaCost
{
  get
  {
    return Red + Blue + Green + Black + White + Colorless;
  }
}

Если хочется чего-нибудь посерьезней, то можно посчитать, удовлетворяет ли кол-во маны определенную стоимость:

public bool EnoughToPay(Mana cost)
{
  if (Red < cost.Red || Green < cost.Green || White < cost.White ||
    Blue < cost.Blue || Black < cost.Black)
    return false;
  // can we pay the colourless price?
  return ((Red - cost.Red) + (Green - cost.Green) + (White - cost.White) +
          (Blue - cost.Blue) + (Black - cost.Black) + Colorless) >= cost.Colorless;
}

Бесцветная мана, в отличии от цветной, понижает степень детерминизма, т.к. мы не можем играть заклинание автоматически если, например, мы заплатили RG за карту стоимостью 1, т.к. непонятно, какого цвета маной нужно расплачиваться.

Гибридная мана


Тут-то все и начинается… ведь до этого момента мы думали что все очень просто, и что можно например взять и предсказуемо распарсить строку 2RG и получить объект типа Mana. А тут раз – и новые правила, причем не только самой механики, но и записи. Ведь как записать символ двойной маны? Скорее всего так: {WB}{WB}{WB}. Вооот, а для этого уже нужен парсер.

Более того, представьте себе что мана расплодилась как сливеры – появилась фиолетовая, пурпурная, и так далее. Легко ли добавить поддержку новой маны в те кусочки кода что я привел выше? Правильно – это нереально сложно. Нужен другой подход.

Прежде чем мы посмотрим на этот самый «другой подход», следует упомянуть о том, что стоимость и оплата – две разные вещи. Точнее они похожи, но стоимость, например может содержать в себе значок X. Например, заплатите XG и получите X жизни. Дабы не плодить сущности, я все же считаю что сущность Mana должна быть одна. Ситуацию с X можно расрулить единично (public bool HasX), а можно чуть-чуть обобщить, так что если вдруг появится карта со стоимостью XY, нам не придется переписывать всю логику. К тому же, бывают ситуации, когда за определенный X можно платить только маной определенного цвета. Это тоже нужно учесть.

Про метапрограммирование


Сдается мне, что в данной задаче нужно метапрограммирование, хотя бы для того чтобы избежать излишней кододупликации а также обезопасить себя от таких случаев когда, например, вдруг нужно добавить поддержку Observable (скажем, через индивидуальные события) без переписывания каждого свойства класса. C# для таких целей не подходит (даже с учетом того, что есть PostSharp). Нужно что-то, что сможет учесть наши цели, а именно:

  • Поддерживать цветную ману с произвольным количеством цветов. То есть, например, добавление сиреневой маны не должно ломать систему и должно нести за собой только незначительные изменения в коде.
  • Поддерживать гибридную ману. Думаю что строенной и более маны не будет, так что можно просто сделать поддержку сдвоенной маны всех сущестующих цветов. Причем при добавлении нового типа «первичной» маны, дополнительных действий по ее «гибридизации» осуществлять не нужно.
  • Правильно поддерживать бесцветную ману а также операции с ней.
  • Поддерживать стандартную нотацию для маны, т.е. иметь парсер который может собирать объект типа Mana из строки.

Итак, давайте посмотрим как можно поэтапно реализовать все вышеперечисленные свойства на языке который поддерживает метапрограммирование. Конечно же я говорю о языке Boo. (Хотя есть еще Nemerle, но в нем я не силен.)

Цвета обыкновенные (попытка номер 2)


N.b. здесь и далее будут идти выкладки сразу на двух языках – на Boo (то, что мы написали) и на C# (то, что в этом увидел Reflector). Сделано это с целью проиллюстрировать действия макросов и мета-методов, т.к. сам по себе Boo, как вы можете догадаться, не будет прозрачен в этом плане.

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

  • MagicTheGathering.Entities для сущностей, таких как Mana. Эту сборку в последствии можно ILMergeить с другими сборками написанными на C# или F#.
  • MagicTheGathering.Meta для наших мета-абстракций, которые будут «собирать» наши сущности.

Поля


Начнем пожалуй с поддержки лесов:



[ManaType("Green""G""Forest")]
class Mana:
  public def constructor():
    pass

Итак, мы навешиваем на наш манакласс аттрибуты разных земель, начиная с лесов. Что же нам нужно от этих аттрибутов? Во-первых, нужно чтобы они добавляли соответствующие поля. Это просто:

class ManaTypeAttribute(AbstractAstAttribute):
  colorName as string
  colorAbbreviation as string
  landName as string
 
  public def constructor(colorName as StringLiteralExpression, 
    colorAbbreviation as StringLiteralExpression, landName as StringLiteralExpression):
    self.colorName = colorName.Value
    self.colorAbbreviation = colorAbbreviation.Value
    self.landName = landName.Value
 
  public override def Apply(node as Node):
    AddField(node)
 
  private def AddField(node as Node):
    c = node as ClassDefinition
    f = [| 
      $(colorName.ToLower()) as Int32
    |]
    c.Members.Add(f)

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

  • Во-первых мы приводим тот элемент к которому применяется аттрибут к типу ClassDefinition
  • Потом, мы создаем поле, используя сплайс (превращаем содержание строки colorName в реальное имя поля) и цитирование (скобки [| и |] превращают наш конструкт в элемент свойства
  • Добавляем собранное свойство к классу.

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

Boo C#
[ManaType("Green""G""Forest")]
class Mana:
  public def constructor():
    pass

[Serializable]
public class Mana
{
    // Fields
    protected int green;
}


Прямые и производные свойства


Хмм, не это ли то, чего мы хотели? :) Как насчет того, чтобы надстроить над этим полем простое свойство? Элементарно, Ватсон – нужно только поменять определение AddField():

private def AddField(node as Node):
  c = node as ClassDefinition
  r = ReferenceExpression(colorName)
  f = [| 
    [Property($r)]
    $(colorName.ToLower()) as Int32
  |]
  c.Members.Add(f)

Давайте теперь создадим проверочное поле, например пусть IsGreen возвращает нам true если карта зеленая, и false если нет. Это свойство мы встретим еще раз, т.к. оно специфично взаимодействует с гибридными картами. Вот моя первая попытка его реализовать:

private def AddIndicatorProperty(node as Node):
  c = node as ClassDefinition
  r = ReferenceExpression(colorName)
  f = [|
    $("Is" + colorName) as bool:
      get:
        return ($r > 0);
  |]
  c.Members.Add(f)

Реализовать производное свойство оказалось тоже очень просто. А вот как все это выглядит в переводе на C#:

[Serializable]
public class Mana
{
    // Fields
    protected int green;
    // Properties
    public int Green
    {
        get
        {
            return this.green;
        }
        set
        {
            this.green = value;
        }
    }
    public bool IsGreen
    {
        get
        {
            return (this.Green > 0);
        }
    }
}

Взаимодействие


Попробуем автогенерировать суммарную стоимость (converted mana cost). Для этого нужно реализовать бесцветную ману что, собственно, не так уж сложно. А вот как автогенерировать сумму всех цветов маны + бесцветную? Для этого мы применим следующий подход:

  1. Во-первых, мы создадим новый аттрибут, а в нем – список тех свойств, которые нужно суммировать
    class ManaSumAttribute(AbstractAstAttribute):
      static public LandTypes as List = []
      ⋮

  2. Теперь, при создании любого «свойства земли», мы будем записывать название в это статическое свойство:
    public def constructor(colorName as StringLiteralExpression, 
      ⋮
      ManaSumAttribute.LandTypes.Add(self.colorName)

  3. А теперь используем это свойство для создания суммы:
    class ManaSumAttribute(AbstractAstAttribute):
      ⋮
      public override def Apply(node as Node):
        c = node as ClassDefinition
        root = [| Colorless |] as Expression
        for i in range(LandTypes.Count):
          root = BinaryExpression(BinaryOperatorType.Addition, 
            root, ReferenceExpression(LandTypes[i] as string))
        p = [|
          public ConvertedManaCost:
            get:
              return $root
        |]
        c.Members.Add(p)


А теперь давайте проверим – добавим поддержку гор (mountain) и посмотрим что эмитируется для свойства ConvertedManaCost. Вот что мы получим:

public int ConvertedManaCost
{
    get
    {
        return ((this.Colorless + this.Green) + this.Red);
    }
}

Как видите, все работает :)

Поддержка гибридной земли


Окей, у нас начало что-то получаться. Я добавляю поддержку всех земель в код и ура, теперь Boo автогенерирует 10 свойств для них, плюс делает сумму. Что еще нужно? Ну, как насчет поддержки гибридной земли. Это конечно посложнее, т.к. нужно брать все пары существующих земель. Но зато это интересно, так почему бы не попробовать?

Принцип такой же как и с генератором суммы – используем статическое поле и заполняем его из аттрибутов разной маны. А потом…. потом совсем сложная вещь. Если коротко – мы ищем все валидные пары маны и для них создаем примерно такие же свойства как и для обычной, «однотипной» маны.

class HybridManaAttribute(AbstractAstAttribute):
  static public LandTypes as List = []
  public override def Apply(node as Node):
    mergedTypes as List = []
    for i in range(LandTypes.Count):
      for j in range(LandTypes.Count):
        unless (mergedTypes.Contains(string.Concat(LandTypes[i], LandTypes[j])) or
               mergedTypes.Contains(string.Concat(LandTypes[j], LandTypes[i])) or
               i == j):
          mergedTypes.Add(string.Concat(LandTypes[i], LandTypes[j]))
    // each merged type becomes a field+property pair
    c = node as ClassDefinition
    for n in range(mergedTypes.Count):
      name = mergedTypes[n] as string
      r = ReferenceExpression(name)
      f = [|
        [Property($r)]
        $(name.ToLower()) as int
      |]
      c.Members.Add(f)

Результат приводить не буду ибо много свойств получается :) Давайте лучше обсудим что делать со свойствами типа IsGreen в случае гибридной маны. Мы ведь уже не можем держать их в аттрибутах однородной маны, т.к. на тот момент о гибридной мане ничего не известно. Давайте их перенесем в отдельный аттрибут. Итак, нам нужно воспрользоваться и гибридными и одиночными свойствами чтобы понять какого цвета карта.

class ManaIndicatorsAttribute(AbstractAstAttribute):   
  public override def Apply(node as Node):
    c = node as ClassDefinition
    for i in range(ManaSumAttribute.LandTypes.Count):
      basic = ManaSumAttribute.LandTypes[i] as string
      hybridLands as List = []
      for j in range(HybridManaAttribute.HybridLandTypes.Count):
        hybrid = HybridManaAttribute.HybridLandTypes[j] as string
        if (hybrid.Contains(basic)):
          hybridLands.Add(hybrid)
      rbasic = ReferenceExpression(basic.ToLower())
      b = Block();
      b1 = [| return true if $rbasic > 0 |]
      b.Statements.Add(b1)
      for k in range(hybridLands.Count):
        rhybrid = ReferenceExpression((hybridLands[k] as string).ToLower())
        b2 = [| return true if $rhybrid > 0 |]
        b.Statements.Add(b2)
      r = [|
        $("Is" + basic):
          get:
            $b;
      |]
      c.Members.Add(r)

Вуаля! В коде выше мы находим все типы маны, которые затрагивает данный тип, и сравниваем их с нулем. Это не самый оптимальный способ вычисления свойства IsXxx, но он работает, хотя на уровне Рефлектора получается нехорошее такое месиво.

Строковое представление и парсер


Для каждого простого типа маны у нас есть строковое представление. Это представление позволяет нам как считать строку, так и получить ее. Начнем с простого – получим строковое представление маны, которое мы будет выдавать через ToString():

class ManaStringAttribute(AbstractAstAttribute):   
  public override def Apply(node as Node):
    b = Block()
    b1 = [|
      sb.Append(colorless) if colorless > 0
    |]
    b.Statements.Add(b1)
    
    for i in range(ManaTypeAttribute.LandTypes.Count):
      land = ReferenceExpression((ManaTypeAttribute.LandTypes[i] as string).ToLower())
      abbr = StringLiteralExpression(ManaTypeAttribute.LandAbbreviations[i] as string)
      b2 = [|
        sb.Append($abbr) if $land > 0;
      |]
      b.Statements.Add(b2)
    
    for j in range(HybridManaAttribute.HybridLandTypes.Count):
      land = ReferenceExpression((HybridManaAttribute.HybridLandTypes[j] as string).ToLower())
      abbr = StringLiteralExpression("{" + 
        (HybridManaAttribute.HybridLandAbbreviations[j] as string) + "}")
      b3 = [|
        sb.Append($abbr) if $land > 0;
      |]
      b.Statements.Add(b3)
      
    b3 = [|
      sb.Append("X"if hasX
    |]
      
    m = [|
      public override def ToString():
        sb = StringBuilder();
        $b
        return sb.ToString()
    |]
    c = node as ClassDefinition
    c.Members.Add(m)

Ну вот, у нас почти все, осталось только добавить самое важное – парсер описания маны, т.е. чтобы программа могла из строки 2GG{RW} создать соответствующий объект. Давайте поделим манапарсер на 3 части – стейтменты разбора базовой маны, гибридной маны, и «всего остального». Итак, базовую ману разобрать не сложно:

// basic land cases are in a separate block
basicLandCases = Block()
for i in range(ManaTypeAttribute.LandTypes.Count):
  name = ManaTypeAttribute.LandTypes[i] as string
  abbr = ManaTypeAttribute.LandAbbreviations[i] as string
  rAbbr = CharLiteralExpression(char.ToUpper(abbr[0]))
  rName = ReferenceExpression(name)
  case = [|
    if (char.ToUpper(spec[i]) == $rAbbr):
      m.$rName = m.$rName + 1
      continue
  |]
  basicLandCases.Statements.Add(case);

C гибридной маной нужно повозиться, так чтобы порядок написания маны (RG или GR) не влиял на парсер. Тем не менее, решение не очень сложное:

// hybrid land cases are in a much smarter block
hybridLandCases = Block()
for i in range(HybridManaAttribute.HybridLandTypes.Count):
  name = HybridManaAttribute.HybridLandTypes[i] as string
  abbr = HybridManaAttribute.HybridLandAbbreviations[i] as string
  // build an appreviation literal
  abbr1 = StringLiteralExpression(abbr)
  abbr2 = StringLiteralExpression(abbr[1].ToString() + abbr[0].ToString())
  case = [|
    if (s == $abbr1 or s == $abbr2):
      m.$name = m.$name + 1
      continue
  |]
  hybridLandCases.Statements.Add(case)

Ну а дальше можно делать сам метод как набор кейсов. Помимо цветной маны, мы добавляем поддержку бесцветной маны, а также символа X:

// the method itself
method = [|
  public static def Parse(spec as string) as Mana:
    sb = StringBuilder()
    cb = StringBuilder() // composite builder
    inHybrid = false // set when processing hybrid mana
    m = Mana()
    for i in range(spec.Length):
      if (inHybrid):
        cb.Append(spec[i])
        continue
      if (char.IsDigit(spec[i])):
        sb.Append(spec[i])
        continue;
      if (spec[i] == '{'):
        inHybrid = true
        continue
      if (spec[i] == '}'):
        raise ArgumentException("Closing } without opening"if not inHybrid
        inHybrid = false
        s = cb.ToString().ToUpper()
        raise ArgumentException("Only two-element hybrids supported"if s.Length != 2
        $hybridLandCases
        raise ArgumentException("Hybrid mana " + s + " is not supported")
      $basicLandCases
      if (char.ToUpper(spec[i]) == 'X'):
        m.HasX = true
        continue;
|]
// add it
c = node as ClassDefinition
c.Members.Add(method)

Результат этого макроса я приводить не буду, т.к. генерируется много кода.

Заключение


Несмотря на то, что мы не разобрали несколько кейсов, такие например как оплата маной определенного заклинания, я пожалуй прервусь – отчасти потому, что Firefox уже начинает падать от количества символов в текстбоксе. Надеюсь что этот пост проиллюстрировал то, насколько сложно делать расширяемые сущности, и то что порой метапрограммирование – не опционально. Кстати, полный код (не ручаюсь за его корректность на данном этапе) можно посмотреть тут. Boo безжалостен.

Ах, да, что касается нашей сущности, то теперь она выглядит вот так:



[ManaType("Green""G""Forest")]
[ManaType("Red""R""Mountain")]
[ManaType("Blue""U""Island")]
[ManaType("Black""B""Swamp")]
[ManaType("White""W""Plains")]
[ManaSum]
[HybridMana]
[ManaIndicators]
[ManaString]
[ManaParser]
class Mana:
  [Property(Colorless)]
  colorless as int
  [Property(HasX)]
  hasX as bool

На этом действительно все. Comments welcome. ■
Теги:
Хабы:
Всего голосов 101: ↑73 и ↓28+45
Комментарии100

Публикации

Истории

Работа

.NET разработчик
68 вакансий

Ближайшие события