Введение в модификации

Самая сильная часть движка — расширяемость. JavaScript был выбран как альтернатива C++/Java, на которых написаны другие проекты (VCMI, fheroes2). Используемый фреймворк (Sqimitive.js) позволяет модифицировать абсолютно все — к примеру, в нем не существует методов (любой метод — это потенциальное событие, на которое можно подписаться) и закрытых полей классов.

Но очень многие вещи не требуют программирования вообще. Вот некоторые примеры.

Система эффектов

В движке определено более 150 типов значений, влияющих на игровые механики, как то (в скобках — использование в SoD):

  • canCombat — возможность проведения битвы в данной точке (Sanctuary)
  • creature_attackAround — число клеток вокруг существа в битве, которые получают повреждения (Hydra)
  • creature_spellEvade — шанс избежать направленного заклинания (Dwarf)
  • hero_actionCost — цена движения по карте для героя (различные типы земли и дорог)
  • hero_experienceGain — мультипликатор получаемого опыта (навык Learning)
  • hero_walkTerrain — типы земли, через которые герой может пройти (лошадь, корабль)
  • hireFree — существа, которые присоединяются к герою бесплатно при посещении им жилища (первоуровневые жилища)
  • randomRumors — список слухов для таверны
  • town_spellCount — число заклинаний для города (Mage Guild, Library)
  • combatCasts — сколько раз герой может колдовать в битве за один раунд
  • creature_canControl — запрет на контроль существа в битве (военные машины, башни)
  • bonus_artifacts — список артефактов, получаемых в результате события (по таймеру, при перемещении)
  • ownable_shroud — число клеток, раскрываемых на карте вокруг шахт и других объектов, кроме городов и героев
  • quest_garrison — существа, с которыми герою предстоит биться при посещении объекта на карте (банки и жилища)
  • worldBonusChances — шансы возможных мировых бонусов (Week/Month of, Plague)

Эффект — это набор селекторов и модификаторов. Селекторы, в том числе тип (выше), задают условия применимости модификаторов. Селекторов сейчас порядка 70 штук, как то:

  • ifPlayer — номер игрока, к которому применяется эффект (Armageddon’s Blade)
  • isEnemy — меняет смысл ifPlayer: номер игрока, для врагов которого применяется эффект
  • ifZ — место происходящего события на карте (1 если подземелье)
  • ifDateMonth — номер месяца в игровой дате (timed event)
  • maxCombats — ограничитель эффекта числом битв, в котором участвовал герой (Foutain of Fortune)
  • whileOwnedPlayer — ограничитель: удаляет эффект, если определенный игрок перестает владеть объектом (доход с шахт)
  • ifGarrisoned — применяется только если данный герой находится в гарнизоне данного города (ограничивает возможность побега)
  • ifSpellLevel — проверяет уровень накладываемого заклинания (иммунитеты драконов)
  • ifRoad — событие происходит на клетке с дорогой определенного типа (расчет стоимости передвижений)
  • ifCreatureUndead — существо в битве является или не является Undead (Holy Word)
  • ifGrantedMin — сколько раз данный объект на карте уже был успешно посещен (Warrior’s Tomb)

Удобной документации по эффектам сейчас нет; информацию следует брать из файлов databank/core.php (см. после строки class Effect { и комментарий до нее) и databank/databank.php (см. после строки class H3Effect; описание селекторов и возможных target).

Для наглядности можно открыть herowo.io — это сайт для тестирования модов; под игровым экраном будет область с кнопкой Copy effects. В ней показаны эффекты, недавно созданные в ответ на игровые события.

Например, в поле ввода можно вставить такой эффект и нажать Create:

{"priority": relative, "target": hero_actionCost, "ifWorldBonus": plague, "modifier": [relative, 10.0]}
  • ifWorldBonus: plague применяет эффект только если в игре в данный момент идет неделя чумы.
  • modifier: [relative, 10.0] читается так: при совпадающих селекторах (target и ifWorldBonus) изменить значение оператором “умножение” на величину 10.0 (т.е. на 1000%).
  • priority задает порядок применения эффекта к финальному значению и обычно совпадает с modifier.

В результате, при наличии такого эффекта в мире, во время недели чумы все герои начинают передвигаться в 10 раз медленнее.

Можно добавить еще какой-нибудь селектор, например, “не применять к AI”:

{"priority": relative, "target": hero_actionCost, "ifWorldBonus": plague, "modifier": [relative, 10.0], "ifPlayerController": "human"}

А таким эффектом можно увеличить шанс возникновения чумы и ее силу:

{"target": worldBonusChances, "modifier": [override, {"3,PLAGUE,0.1": 100}], "priority": override}

Правда, в HeroWO мировые бонусы определяются раз в день, а не раз в неделю, поэтому если мы не хотим, чтобы 6 дней в неделю из 7 был чума, нужно добавить селектор (день 1 = понедельник, неделя 1 = первая неделя месяца):

{"target": worldBonusChances, "modifier": [override, {"3,PLAGUE,0.5": 100}], "priority": override, "ifDateDay": 1, "ifDateWeek": 1}


Вот пример поинтереснее, назовем его артефактом Hand of Midas:

{"target": quest_choices, "modifier": [diff, "exp500", "exp1000", "exp1500"], "ifBonusObjectClass": 944, "priority": diff}
  • Значение для target: quest_choices вычисляется при взаимодействии с большинством объектов на карте. Результатом является список меток, из которых игрок может выбрать желаемое действие.
  • modifier с оператором diff исключает из значения перечисленные элементы.
  • ifBonusObjectClass — тип объекта на карте (аналог class+subclass в OBJECTS.TXT). 944 = Treasure Chest. Число получено из файла classes.txt в папке банка данных (databanks/sod/classes.txt внутри Workbench).

Treasure Chest, он же сундук с сокровищами, стандартно имеет такие эффекты:

array_fill_keys($c_treasureChest, [
  ['quest_chances', $chances('ge500/32 ge1000/32 ge1500/31 chArtT/5')],
]),

'ge500'   => [['quest_choices', [$append, 'gold1000', 'exp500']]],
'ge1000'  => [['quest_choices', [$append, 'gold1500', 'exp1000']]],
'ge1500'  => [['quest_choices', [$append, 'gold2000', 'exp1500']]],

Читается это так: при встрече с объектом класса treasureChest, бросить кубик с шансами: по 32% на исходы ge500 и ge1000, 31% на ge1500 и 5% на chArtT. В свою очередь, первые три дают игроку выбор между типами gold1000/1500/2000 и exp500/1000/1500. Описания этих типов, как и chArtT, для простоты я здесь приводить не буду, они есть по ссылке выше.

Так вот, наш эффект применяется после стандартных quest_choices, так что в результате вычисленное значение никогда не будет содержать exp500/1000/1500. Игрок будет всегда получать либо золото (без возможности выбрать опыт), либо артефакт.

Как вариант, можно было бы перекрыть не quest_choices, а quest_chances, получая из всех сундуков всегда только артефакты (или что-то другое, например, существ — как это сделано для Pandora’s Box).

Банк данных

Банк данных HeroWO, по-заморски “databank” — хранилище независимых от карты данных. Данные, в основном, получаются из TXT-файлов SoD (или модифицированных). Однако карта может менять их произвольно (в отличие от SoD, где допускается изменение только некоторых вещей — например, параметров героя, но не здания).

Например, можно добавить новое здание City Lights к Castle, которое, будучи построенным, будет раскрывать по 1 дополнительной клетке вокруг шахт и других объектов. Для начала добьемся нужного эффекта добавив, гм, эффект:

{"target": ownable_shroud, "modifier": 1}

1 — это короткая форма записи modifier: [delta, +1], то есть сложение результата с числом.

В такой форме эффект работает для всех игроков, т.к. у него только один селектор — target. Но это мы исправим позже.

Добавим здание в банк данных. Для этого в папке с картой в формате HeroWO (это набор JSON-файлов, в том числе map.json) создадим папку databank, а в ней файл buildings.txt с таким содержимым:

{
  "0": {
    "0": {
      "-1": {
        "name": "City Lights",
        "description": "Increases scouting radius around mines and dwellings that you own.",
        "cost_wood": 5,
        "cost_mercury": 0,
        "cost_ore": 5,
        "cost_sulfur": 0,
        "cost_crystal": 0,
        "cost_gems": 0,
        "cost_gold": 1000,
        "require": [buildingsID.cityHall],
        "town": [townsID.castle],
        "image": {
          "0": {
            "0": {
              $"{townsID.castle}": {
                "hallImage": ["HALLTOWR", 0, 26],
                "scapeImage": "TBTWHOLY",
                "scapeOutline": "TOTHOLYA",
                "icon": "BOTGRAIL"
              }
            }
          }
        },
        "effects": {
          "0": {
            "0": {
              "0": {
                "target": effect.target.ownable_shroud,
                "priority": effect.priority.of.delta,
                "modifier": 1,
                "ifPlayer": true
              }
            }
          }
        }
      }
    }
  }
}
  • “-1” — внутренний номер добавляемого объекта; начинается с -1 и растет вниз. Если 0 или выше, то вместо добавления заменяет стандартный объект.
  • require задает требования к постройкам. Построить наш City Lights можно будет только при наличии предпоследнего улучшения для магистрата.
  • town ограничивает здание определенным типом города.
  • image — сложное поле, задает картинки для городского ландшафта, окна Hall и диалогов. Так как подходящей картинки у нас нет, указываем данные Skyship из Tower.
  • effects — запакованные эффекты, которые применяются к игроку, у которого есть это здание. Это самое важное для нас поле; оно похоже на то, что мы делали ранее, но с необычным значением для ifPlayer: true.


Движок заменяет true на номер игрока, у которого есть постройка. Такой трюк работает со многими селекторами (например, ifObject = конкретный объект на карте, ifX = точка на карте), так что, например, наш Hand of Midas можно было бы прописать в эффектах артефакта, задав селектору ifObject значение true, что читается как “для героя-владельца артефакта”.

Такой эффект будет давать 1 клетку на каждый город с City Lights. Если это нежелательно, можно использовать особое поле stack, запрещающее применять более одного эффекта данного типа к значению (например, оно используется для однократного применения бонуса морали к отряду).

Программирование модулей

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

Тем не менее, в HeroWO есть и система модулей (плагинов) на JavaScript. На herowo.io, кроме панели эффектов, есть панель подключаемых скриптов (кнопка Add script). Предлагаю нажать на нее и указать такой URL:

https://herowo.game/pub/mod-ex-1.js

По этому URL находится такой скрипт:

define([], function () {
  var coords = ''

  return {
    start: function (cx) {
      $('<button id=mybtn>')
        .text('Go')
        .insertBefore('#controls .map-size')
        .click(function () {
          coords = prompt('Enter X-Y coordinates to center on:', coords) || ''
          var pos = coords.match(/(\d+)\D+(\d+)/)
          if (pos) {
            cx.screens().forEach(function (sc) {
              sc.set('mapPosition', [pos[1], pos[2]])
            })
          }
        })

      $('<style id=mystyle>')
        .text(
          `
            #mybtn {
              display: block;
              background: red;
            }
          `
        )
        .appendTo('body')
    },

    stop: function () {
      $('#mybtn,#mystyle').remove()
    },
  }
})
  • define — синтаксис Require.js. В первом параметре-массиве задаются зависимости модуля, во втором — его код. В нашем случае зависимостей нет (вернее, мы используем глобальную переменную $ — jQuery; это не красиво, но для простоты пойдет).
  • Функция модуля должна вернуть объект с двумя методами: start и stop. Оба получают объект cxContext, точка входа в работающий экземпляр движка.
  • В start мы добавляем в документ новую кнопку Go на верхнюю отладочную панель. По щелчку запрашиваем у пользователя координаты на карте и центрируем на них.
  • Также мы добавляем в документ CSS-стили, выделяя нашу кнопку.

Более сложный пример, где мы встраиваемся внутрь движка в виде экранного модуля (ScreenModule), рисующего пунктирную линию вокруг жилищ с существами, доступными для найма:

https://herowo.game/pub/mod-ex-2.js

define(['DOM.Common'], function (Common) {
  var Mod = Common.Sqimitive.extend({
    mixIns: [Common.ScreenModule],
    _map: null,

    events: {
      render: function () {
        this._map = this.sc.modules.nested('HeroWO.DOM.Map')
        var dwelling = this.map.constants.object.type.dwelling

        this.autoOff(this.map.objects, [
          'ochange_p_' + this.map.objects.propertyIndex('available'),
          function (n) {
            var id = this.map.objects.fromContiguous(n).x

            if (this.map.objects.atCoords(id, 0, 0, 'type', 0) == dwelling) {
              this._updateObject(id)
            }
          },
        ])

        this.map.byType.findAtCoords(
          dwelling, 0, 0,
          0,
          this._updateObject,
          this
        )
      },
    },

    _updateObject: function (id) {
      this._map.objectEl(id).classList.toggle('mymod-available',
        this.map.objects.readSubAtCoords(id, 0, 0, 'available', 0)
          .find(0, count => count > 0 || null) != null)
    }
  })

  var style = $('<style>')
    .text(
      `
        .mymod-available {
          outline: .1em dashed lime;
        }
      `
    )

  return {
    start: function (cx) {
      style.appendTo('body')
      cx.autoAddModule('mymod', Mod)
    },

    stop: function (cx) {
      $('.mymod-available').removeClass('mymod-available')
      cx.screens().forEach(sc => sc.unlist('mymod'))
      style.remove()
    },
  }
})
  • define объявляет зависимость на общий для экранных модулей класс с полезными вещами.
  • Common.Sqimitive.extend() — способ создания нового класса в фреймворке Sqimitive. Поля класса передаются в виде объекта; например, _map — новое поле, по умолчанию null, причем подчеркивание в начале обозначает, что к нему не следует обращаться снаружи от того класса, где оно определено (protected).
  • render — событие, на которое мы подписываемся; возникает однократно в жизни каждого модуля в момент отрисовки интерфейса. Есть и другие события, например, attach, которое вызывается раньше, в момент загрузки.
  • this.sc — ссылка на экран, в который добавлен наш модуль. Экранов может быть несколько — например, при игре в режиме hot seat. Из экрана мы получаем ссылку на модуль отрисовки игровой карты (adventure map).
  • this.map — ссылка на логическую карту (только данные). Содержит в себе данные игроков, объектов, эффектов, тумана войны и прочего.
  • this.autoOff() — подписывается на события в другом объекте, причем при удалении нашего модуля или этого объекта подписка отменяется.
  • ochange_p_N — событие, возникающее в хранилище данных объектов adventure map (это земля, дороги, сундуки, мельницы, города, герои — и в том числе жилища). Если точнее, оно возникает при изменении ("o"bject “change”) поля ("p"roperty) номер “N” (в нашем случае — available).
  • function (n) принимает на вход номер (n) измененного объекта и получает по нему ID, проверяет тип объекта (мы не работаем с городами, например) и обновляет его.
  • После подписки на событие проходим по существующим объектам, применяя к ним начальное состояние рамки. map.byType — это тот же map.objects, только ограниченный нужным нам типом объектов; это быстрее, чем перебирать все подряд.
  • Так как все игровые объекты в данной версии — это DOM-узлы (из-за чего, собственно, и проистекает чрезмерное потребление памяти и подлагивания), то для изменения внешнего вида объекта наш _updateObject просто добавляет CSS-класс mymod-available в случае, если в поле-массиве available этого конкретного объекта есть хотя бы одно число выше 0. (available хранит число доступных существ для каждого типа существа в отдельности.)
  • Наконец, в start мы добавляем наш стиль к документу и наш модуль к каждому (autoAddModule) экрану в игровом контексте (cx).
1 Like

{“priority”: 344, “target”: 47, “ifWorldBonus”: 3, “modifier”: [11, 10.0]}

Почему 47, почему 344? И в принципе очень много магических чисел - это плохо, надо примерно так:

{“priority”: $$hight+4, “target”: $$hero_actionCost, “ifWorldBonus”: $$ifWorldBonus_Plague, “modifier”: [11, 10.0]}

Доработал форму и обновил примеры:

{"priority": 344, "target": hero_actionCost, "ifWorldBonus": plague, "modifier": [relative, 10.0]}

priority осталось числом, ибо считается довольно сложно, но при изменении эффектов, добавленных движком, это число уже известно, а при добавлении собственных эффектов его достаточно просто сделать большим (1000+).

Некоторые другие числа (ifBonusObjectClass, значение для worldBonusChances) берутся из констант (databanks/sod/constants.json) и их замена не такая тривиальная, поэтому оставлено на будущее.

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

1 Like

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