Реализация «Героев меча и магии III: Дыхание смерти» для браузера
Сайт: herowo.game (запустить Tutorial)
Код: https://github.com/HeroWO-js
Число строк в проекте - порядка 100 тысяч.
JavaScript: клиент и сервер. PHP: конвертер карт, подготовка данных.
По клику на картинки можно посмотреть короткие ролики, если лень открывать сайт:
- Готова половина заклинаний, половина интерактивных объектов на карте и практически все существа, артефакты, навыки, городские здания и все города (включая Conflux). Подробный список.
- Сделан мультиплеер, причем без глобальных блокировок: игроки не только ходят одновременно (как в Age of Wonders), но и битвы проходят параллельно. Блокируются только отдельные объекты (так, что даже один игрок в теории может одновременно контролировать несколько битв).
- Есть туман войны, причем в расширенном варианте (закрыто, открыт ландшафт, открыто). Это дополнение (открыт ландшафт), как и все остальные отличия от SoD, отключаемы (режим Classic в игровых настройках).
- Есть простенький AI. Есть сохранение/загрузка.
- Механизм звуковых эффектов написан, но не все данные найдены/заполнены (чисто техническая работа, как и заполнение препятствий для битв и многие другие подобные задачи).
- Многие вещи заложены в коде, но не выведены наружу. Например, в битве могут участвовать неограниченное число сторон (как в Age of Wonders), на карте может быть сколько угодно уровней (хоть 3 как в AoW, хоть 5, хоть 10), есть запись реплеев (но нет проигрывания) и режим наблюдателя (observer, но пока отражаются только движения экрана карты и нет полезных кнопок, например, для отключения слежения). Вообще, движок модульный, и в перспективе позволяет вместо модуля «Героев 3» подключить модуль AoW или Disciples, с сохранением большей части функционала.
- Многое сейчас запрограммировано неоптимально, из-за чего имеем чрезмерное потребление памяти и подлагивания. В особенности на это влияет размер карты; S-M, иногда L играются терпимо.
Модификации
Актуальная версия «Введения в модификации»: https://forum.herowo.net/t/topic/47
Самая сильная часть движка - расширяемость. 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)
- 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).
Например, в поле ввода можно вставить такой эффект и нажать Create:
Код
{"priority": 344, "target": 47, "ifWorldBonus": 3, "modifier": [11, 10.0]}
- target: 47 обозначает hero_actionCost. Эффект применяется, только если запрошено значение для этого типа.
- ifWorldBonus: 3 срабатывает только, если в игре в данный момент действует состояние Plague.
- modifier: [11, 10.0] читается так: при совпадающих селекторах (target и ifWorldBonus) изменить значение оператором 11 (relative, т.е. умножение) на величину 10.0 (т.е. на 1000%).
Можно добавить еще какой-нибудь селектор, например, "не применять к AI":
Код
{"priority": 344, "target": 47, "ifWorldBonus": 3, "modifier": [11, 10.0], "ifPlayerController": "human"}
А таким эффектом можно увеличить шанс возникновения чумы и ее силу:
Код
{"target": 154, "modifier": [16, {"3,PLAGUE,0.1": 100}], "priority": 500}
Правда, в HeroWO мировые бонусы определяются раз в день, а не раз в неделю, поэтому если мы не хотим, чтобы 6 дней в неделю из 7 был чума, нужно добавить селектор (день 1 = понедельник, неделя 1 = первая неделя месяца):
Код
{"target": 154, "modifier": [16, {"3,PLAGUE,0.5": 100}], "priority": 500, "ifDateDay": 1, "ifDateWeek": 1}
Вот пример поинтереснее, назовем его артефактом Hand of Midas:
Код
{"target": 146, "modifier": [17, "exp500", "exp1000", "exp1500"], "ifBonusObjectClass": 944, "priority": 10000}
- target: 146 = quest_choices. Это значение вычисляется при взаимодействии с большинством объектов на карте. Результатом является список меток, из которых игрок может выбрать желаемое действие.
- modifier - оператор 17 = diff; исключает из значения перечисленные элементы.
- ifBonusObjectClass - тип объекта на карте (аналог class+subclass в OBJECTS.TXT). 944 = Treasure Chest.
- priority задает порядок применения эффекта к финальному значению. Большое значение заставляет его применяться после стандартных.
Код
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']]],
['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": 137, "modifier": 1}
137 = ownable_shroud, а 1 - это короткая форма записи modifier: [5, +1], где 5 = delta, то есть сложение результата с числом.
В такой форме эффект работает для всех игроков, т.к. у него только один селектор - target. Но это мы исправим позже.
Добавим здание в банк данных. Для этого в папке с картой в формате HeroWO (это набор JSON-файлов, в том числе map.json) создадим папку databank, а в ней файл buildings.json с таким содержимым:
Код
[
[
{
"-1": {
"name": "City Lights",
"description": "Increases scouting radius around mines and dwellings that you own.",
"cost_gold": 1000,
"cost_wood": 5,
"cost_ore": 5,
"require": [2],
"town": [0],
"image": [
["HALLTOWR", 0, 26],
"TBTWHOLY",
"TOTHOLYA",
[], [],
0, 0,
50,
"BOTGRAIL",
false, false, false, false, false, false, false
],
"effects": [false,false,false,true,false,false,false,false,false,false,false,false,false
,false,false,false,false,false,false,false,false,137,false,false,false,false,fal
s
e,false,false,false,2,false,false,false,false,false,false,false,false,false,fals
e
,false,false,false,false,false,false,false,false,false,false,false,false,false,f
a
lse,false,false,false,false,false,false,false,false,false,false,false,false,fals
e
,false,false,false,false,false,false,false,false,false,false,false,false,false,f
a
lse,false,false,false,false,false,false,false,false,false,false,false,false,fals
e
,false,false,false,false,false,false,false,false,false,false,false,false,false,f
a
lse,false,false,false,false,false,false,false,false,false,false,false,false,fals
e
,false,false,false,false,false,false]
}
}
]
]
[
{
"-1": {
"name": "City Lights",
"description": "Increases scouting radius around mines and dwellings that you own.",
"cost_gold": 1000,
"cost_wood": 5,
"cost_ore": 5,
"require": [2],
"town": [0],
"image": [
["HALLTOWR", 0, 26],
"TBTWHOLY",
"TOTHOLYA",
[], [],
0, 0,
50,
"BOTGRAIL",
false, false, false, false, false, false, false
],
"effects": [false,false,false,true,false,false,false,false,false,false,false,false,false
,false,false,false,false,false,false,false,false,137,false,false,false,false,fal
s
e,false,false,false,2,false,false,false,false,false,false,false,false,false,fals
e
,false,false,false,false,false,false,false,false,false,false,false,false,false,f
a
lse,false,false,false,false,false,false,false,false,false,false,false,false,fals
e
,false,false,false,false,false,false,false,false,false,false,false,false,false,f
a
lse,false,false,false,false,false,false,false,false,false,false,false,false,fals
e
,false,false,false,false,false,false,false,false,false,false,false,false,false,f
a
lse,false,false,false,false,false,false,false,false,false,false,false,false,fals
e
,false,false,false,false,false,false]
}
}
]
]
- "-1" - внутренний номер добавляемого объекта; начинается с -1 и растет вниз. Если 0 или выше, то вместо добавления заменяет стандартный объект.
- require: [2] задает требования к постройкам. 2 = cityHall, то есть построить наш City Lights можно будет только при наличии предпоследнего улучшения для магистрата.
- town: [0] ограничивает здание определенным типом города. 0 = Castle.
- image - сложное поле, задает картинки для городского ландшафта, окна Hall и диалогов. Так как подходящей картинки у нас нет, указываем данные Skyship из Tower.
- effects - запакованные эффекты, которые применяются к игроку, у которого есть это здание. Это самое важное для нас поле; его значение можно получить по кнопке Embed, но есть одна тонкость.
Тонкость в том, что запакованный эффект на самом деле задан так:
Код
{"target": 137, "modifier": 1, "ifPlayer": true}
Селектор ifPlayer имеет особое значение 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()
},
}
})
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-стили, выделяя нашу кнопку.
Код
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()
},
}
})
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).