Самая сильная часть движка — расширяемость. 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. Оба получают объект cx — Context, точка входа в работающий экземпляр движка.
- В 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).