image

Что это и зачем?


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

class CastSpellCommand extends Command {
  constructor (source, target, spell) {
    this.source = source;
    this.target = target;
    this.spell  = spell;
  }
  execute () {
    const spellAbility = this.source.getAbility(SpellCastAbility);

    // может быть много совершенно разных if (!cond) return error
    if (spellAbility == null) {
      throw new Error('NoSpellCastAbility');
    }    

    this.addChildren(new PayManaCommand(this.source, this.spell.manaCost));
    this.addChildren(this.spell.getCommands(this.source, this.target));
    
    // из другой команды:
    healthAbility.health = Math.max( 0, resultHealth );
  }
}

// отрисовка:
async onMeleeHit (meleeHitCommand) {
  await view.drawMeleeHit( meleeHitCommand.source, meleeHitCommand.target );
}

async onDealDamage (dealDamageCommand) {
  await view.showDamageNumbers( dealDamageCommand.target, dealDamageCommand.damage );
}

Что можно сделать?


Рассмотрим несколько подходов разного характера:

Наблюдатель


class Executor extends Observer {/* ... */}
class Animator extends Observer {/* ... */}

Классическое, хорошо известное программистам решение. Понадобится лишь минимально изменить его, чтобы проверять значения, возвращаемые наблюдателями:

this.listeners.reduce((result, listener) => result && listener(action), true)

Недостаток: наблюдатели должны подписываться на события в правильном порядке.

Если сделать обработку ошибок, аниматор сможет также показывать анимации не удавшихся действий. Можно передавать наблюдателям предыдущее значение, концептуально решение остается тем же. Вызываются ли методы наблюдателей или callback-функции, используется ли вместо свертки обычный цикл — детали не так существенны.

Оставить как есть


И в самом деле. У текущего подхода есть как недостатки, так и достоинства:

  1. Проверка возможности выполнения команды требует выполнения команды
  2. Жестко зашиты аргументы в меняющемся порядке, условия, префиксы методов
  3. Циклические зависимости (команда < заклинание < команда)
  4. Дополнительные сущности на каждое действие (метод заменен методом, классом и его конструктором)
  5. Чрезмерные знания и действия отдельной команды: от игровой механики до ошибок синхронизации и прямой манипуляции чужими свойствами
  6. Интерфейс вводит в заблуждение (execute не только вызывает, но и добавляет команды через addChildren; который, очевидно, делает наоборот)
  7. Сомнительная необходимость и реализация рекурсивных команд как таковых
  8. Класс-диспетчер, если он есть, не выполняет свои функции
  9. [+] Якобы единственный способ анимации на практике, если анимации нужны полные данные (указано в качестве основной причины)
  10. [+] Вероятно, иные причины

С частью недостатков можно разобраться отдельно, но остальные требуют более кардинальных изменений.

ad hoc


  • Условия выполнения команды, тем более игровая механика, должны быть вынесены из команд и оформлены отдельно. Условия могут меняться в рантайме, а выделение неактивных кнопок серым цветом встречается на практике задолго до того, как начинается работа над анимацией, не говоря уже о логике. Чтобы избежать копирования, может иметь смысл хранить общие условия в прототипах способностей.
  • Вернуть методы, в сочетании с предыдущим пунктом отпадет необходимость таких проверок:

    const spellAbility = this.source.getAbility(SpellCastAbility);
    // может быть много совершенно разных if (!cond) return error
    if (spellAbility == null) {
      throw new Error('NoSpellCastAbility');
    }  

    Джаваскриптовый движок сам покажет правильный TypeError при ошибочном вызове метода.
  • Такие знания команде тоже не нужны:

    healthAbility.health = Math.max( 0, resultHealth );
  • Чтобы решить проблему аргументов, меняющихся местами, их можно передавать объектом.
  • Хотя вызывающий код не доступен для изучения, по видимому, большая часть недостатков произрастает из-за неоптимального способа вызова игровых действий. Например, обработчики на кнопках обращаются к каким-то конкретным сущностям. Поэтому замена их в обработчиках на конкретные команды кажется вполне естественной. При наличии диспетчера, вызвать за действием анимацию намного проще, ей можно передать ту же информацию, так что недостатка в данных у нее не будет.

Очередь


Чтобы показывать анимацию действия после выполнения действия, достаточно добавлять их в очередь и запускать примерно так, как в решении 1.

[
  [ walkRequirements, walkAction, walkAnimation ],
  [ castRequirements, castAction, castAnimation ],
  // ...
]

Не имеет значения, какие сущности лежат в массиве: функции, забинженные с нужными параметрами, экземпляры пользовательских классов или обычные объекты.
Ценность такого решения — простота и прозрачность, легко сделать скользящее окно для просмотра N последних команд.

Хорошо подходит для прототипирования и отладки.

Класс-дублер


Делаем класс анимаций для способности.

class MovementAbility {
  walk (...args) {
    // action
  }
}

class AnimatedMovementAbility {
  walk (...args) {
    // animation
  }
}

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

Хорошо подходит, когда нужен фактически тот же набор методов, их можно автоматически проверить и протестировать.

Комбинации методов


const AnimatedMovementAbility = combinedClass(MovementAbility, {
  ['*:before'] (method, ...args) {
    // call requirements
  },
  ['*:after'] (method, ...args) {
    // call animations
  }
})

Было бы интересной возможностью при нативной поддержке языком.
Хорошо использовать, если такой вариант получается производительнее, хотя на самом деле нужен прокси.

Прокси


Оборачиваем способности в прокси, отлавливаем в геттере методы.

new Proxy(new MovementAbility, {/* handler */})

Недостаток: многократно медленнее обычных вызовов, что для анимации не так существенно. На сервере, обрабатывающем миллионы объектов, замедление было бы заметно, но на сервере не нужна анимация.

Promise


Можно конструировать цепочки из Promise, но есть и другой вариант (ES2018):

for await (const action of actionDispatcher.getActions()) {
  // 
}

getActions возвращает асинхронный итератор по действиям. Метод next итератора возвращает Deferred Promise следующего действия. После обработки событий от пользователя и сервера, вызываем resolve(), создаем новый promise.

Команда получше


Создаем объекты наподобие такого:

{actor, ability, method, options}

Код сводится к проверке и вызову метода способности с параметрами. Самый простой и производительный вариант.

Примечание


Комментарии (0)