Русский
Русский
English
Статистика
Реклама

Godot

Из песочницы Plague M.D. Я остался в России и меня поглотила Чума. Сопли и нытье прогера

28.09.2020 12:18:44 | Автор: admin
Мы с друзьями делаем игру. Так было не всегда, а я вообще жил в Австрии.


Plague M.D. игра, которую сделал я и мои друзья на Godot. Сейчас она доступна в на мобильных платформах, а 14 октября выйдет в steam. Как это произошло, я сам не понял.



Полгода назад я застрял в России из-за COVID после сокращения офиса в Австрии. Обучение в Вене было окончено, так что меня ждал огромный мир IT нашей прекрасной страны. Рынок вакансий предлагал карьеру в области веб, 8 часов в день, 5 дней в неделю и все в комфортном офисе, но Только после окончания пандемии. Именно тогда мои друзья предложили мне сделать игру. Кто откажется от такого?

Plague M.D. это моя первая самостоятельная игрушка, хотя лет пять назад я подрабатывал в команде геймдизайнеров, но быть частью процесса и стать единственным программистом совсем разные вещи.

И вот, что я запомнил за последние пять месяцев своей жизни в следующих 13 пунктах:

1. Есть всего один специалист это я


Движок, который команда выбрала, библиотека, если угодно, модный-стильный-молодёжный. На западе в Европе некоторые студии постепенно переходят на него, переобучая специалистов. В РФ он пришёл недавно, поэтому основа контингента его сообщества школьники с богатым внутренним миром и аутичные задры. Толковых профи мало, документация плохо локализована, а ее оригинал грешит анти-очевидностью, как препод по матану, который внезапно пропускает 20 строчек формулы, потому что это и так очевидно, че вы.



2. Я застрял в рамках инструмента


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

3. Одиночество с кодом


Понимание того, что весь код никто не увидит расслабляет, начинаешь писать грязно, иногда не задумываясь о будущих правках. Из-за чего многие вещи приходилось переделывать по 2-3 раза. Поговаривают, это обычный результат в разработке (к сожалению):



4. Большая нагрузка
Очевидно, вытекающая из предыдущего пункта проблема. В какой-то момент работы с Unity начинаешь встречать специалистов, с которыми можно и нужно советоваться. Кроме того, тему менторства никто не отменял любому IT комьюнити очень нужны новые специалисты. Увы, на Godot обученные работники хотят зарплату более 9000, а прочие ленятся переучиваться из-за боязни оказаться за бортом мейнстримной технологии.

5. Слишком много багов, слишком много лука


Глаз замыливается постоянно, особенно после долгой долбежки какой-то проблемы типа неработающего диалогового модуля, когда радуешься, что хоть как-то это работает. Отсюда выходят странные ошибки вроде бесконечного числа луковиц, которыми можно завесить все пространство дисплея и пройти игру. Также стоит отметить те баги, которые сами исправились. Их множество, про некоторые я просто забывал, а потом обнаруживал, что всё уже решилось. Возможно, они всё ещё в игре.



6. Чит-коды
Несмотря на то, что Годот очень удобен для теста игры и её модулей, иногда приходилось залипать в игровой процесс по несколько минут. Ради ускорения этого процесса пришлось идти на разные ухищрения, ведь классические методы тестирования не подходят для такого продукта.
Так для тестировщиков была придумана комбинация кликов на курицах в игровой деревне, которая переключала уровни. Но, как только был прикручен модуль сохранений, она начала выдавать совсем уже необычный результат. Этот чит-код все еще в игре, так что можете попытаться активировать его. Не удивляйтесь, если Жан умрет, все дело в курице:



7. Я сжимаю как батя. Оптимизация


Из-за того, что львиная доля изображений была нарисована художником и обработана в хайрез, а также из-за безумной любви дизайнера скидывать материал 5:1, постоянно приходится ужиматься, внося правки в размеры файлов, форматы (вместо wav ogg) и используя лаконичность мысли в коде.

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

8. Ад архивов и бэкапов


Из-за объема графических материалов, звука и музыки сразу остро встали вопросы сохранения версий и коммуникации с коллегами. Конечно, сразу был использован гит и облака. Но для коллег и внутреннего общения использовались ТОНН архивов. Спустя месяц я понял, что запоминать и отсылать к числовым названиям версий тупо никто не помнит. Отсюда появились безумные имена вроде Войны бобров и Меховые селёдкины шубы.

9. Где вообще я работаю?


Из-за ковида полпроекта пришлось проработать удаленно. Были моменты, когда мы собирались в офисе агентства, в котором раньше работали мои друзья.

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

Главный бородатый мужик помогал нам не умереть с голоду, когда было совсем тяжело, искренне верил в игру, любезно предоставил место в офисе для наших собраний и аккаунты разработчика в Google Play и App Store. Смена обстановки и режима работы помогала решать задачи.

10. И никому не объяснишь


Сидишь, никого не трогаешь, починяешь абстрактный примус, и тут из-за угла офиса выруливает один из бородатых мужиков и говорит: хочу играть сейчас на своём айфоне, го показывать. И ты начинаешь объяснять, что у айфонов закрыта файловая система, а у тебя не мак и тут надо настроить, и дело не 5 минут Проблема, висевшая в нашем общении 2 месяца, пока не был найден макбук. Так мы нашли язык с внешним миром.

11. Про команду и доверие


У нас малотоксичная команда, все всех слушают и стараются избегать прямых конфликтов. Не думаю, что это только из-за уважения в быту тусовка токсична. Такой подход экономит время. Сраться долго.

12. Про дедлайны


Дело даже не в сроках. Если кодишь архитектуру, 100% завалишь, потому что предпочтешь опоздать, чем выдать нерабочий продукт. Эта хрень тупо должна работать. Иначе ты хреновый работник. Хирург не говорит, что пациент был так себе пациентом, если не справляется.



В общем и целом, я много опаздывал по чуть-чуть. Но игра все равно вышла.

13. Если игра выпущена, это не значит, что ты свободен. У Добби будет еще много правок


Россия откроет границы в декабре 2020 года, я уеду в Австрию или нет.
Подробнее..

Как собрать паука в Godot, Unigine или PlayCanvas

04.01.2021 22:21:38 | Автор: admin
С наступившим 21-м годом 21-го века.
В данной статье пробегусь по особенностям работы в трёх игровых движках, на примере написания кода для паукообразного средства передвижения.




Общая структура паукомобиля подразумевалась следующая есть 6 лап, на каждой копия управляющего скрипта, который отслеживает перемещение основной конструкции и переставляет лапу в новую позицию, когда она отдаляется на определённое расстояние.
Для более точной посадки лап планировалось добавить и рейкасты, но, допустим, в том же Godot я хотел сделать паука для игры с видом сверху, при котором подобная точность не особо нужна.
Таким образом задача состояла в формировании нужной структуры внутри движка и написании кода отдельной лапки. Вот что получилось в разных средах разработки:



Godot


Здесь у меня уже был готов небольшой проект с машинками и паука я решил добавить в одну из сцен (префабов), содержащую в себе подкласс машинок, которые не имеют колёс.
Сама сцена specific_base устроена таким образом, что в основе узел-пустышка, который просто висит где-то в мире, без движения, а по миру перемещается kinematic body внутри него. Камера находится внутри сцены, но вне body, просто следуя за ним.



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



Сами лапы для удобства разместил здесь же, в этой сцене, но вне body. Управляющий каждой из них скрипт, помимо переноса лапы, будет постоянно разворачивать её к центру паука.


Код внутри редактора Godot

Пишем код. Я использую GDScript, потому как особого смысла писать именно на C# в Годо не вижу (не настолько фанат фигурных скобочек):

extends Spatialexport var distance = 2.5#максимальная дистанция, после которой случится перерасчётexport var step = 1#переменная для дополнительного смещения лап (вещь необязательная)#ссылки на центр паука и одну из позиций лап, а также элементы для их храненияexport (NodePath) var spidercenter = nullvar trg_centerexport (NodePath) var spiderleg = nullvar trg_leg#переменные для расстояний по осям x и zvar x_dis = 0.0var z_dis = 0.0#переменная-таймер, а также флагvar time_lag = -1.0# инициализацияfunc _ready():self.hide()#скрыть лапуtrg_center = get_node(spidercenter)#запомнить объектыtrg_leg = get_node(spiderleg)LegPlace()#один раз вызвать установку лапки на позицию# основной циклfunc _process(delta):        #развернуть лапу в направлении центра паука. можно ввести таймер, чтобы делать это через малые интервалыself.look_at(trg_center.global_transform.origin, Vector3(0,1,0))        #включить видимость, если лапа была невидимой. это делалось для того, чтобы показывать её снова, после того как она скрывается в момент перестановки (чтобы перестановка выглядела как появление лапы в новой позиции уже развёрнутой в нужную сторону, а не перенесением с последующим разворачиванием). на самом деле можно было вынести внеочередной разворот и последующий показ лапы в LegPlaceif self.visible == false: self.show()if time_lag>=0:#если флаг-таймер запущен, то наращивать его значениеtime_lag +=1*delta if time_lag>0.06:#при истечении задержки сбросить флаг и вызвать перерисовкуtime_lag = -1.0LegPlace()else:#пока флаг неактивен считать дистанции от лапы до позиции лапы по двум осямx_dis = abs(trg_leg.global_transform.origin.x - self.global_transform.origin.x)z_dis = abs(trg_leg.global_transform.origin.z - self.global_transform.origin.z)if (x_dis + z_dis) > distance:#если дистанция больше лимита, запустить флагtime_lag = 0.0passfunc LegPlace():#собственно, сама функция перестановки лапыself.hide()step = step*(-1)self.global_transform.origin = trg_leg.global_transform.origin+Vector3(0,0,0.5*step)



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



Unigine


После того, как была готова реализация для Godot, я решил перенести это решение и в Unigine engine. Там у меня тоже имелся проект с машинками, правда чтобы не перегружать его я сделал отдельный паучий форк, чтобы впоследствии, наверное, вовсе убрать из него колёса и развивать как-то отдельно.



Тут имеется вся сцена игрового мира, в котором располагается корпус машинки игрока. В начале игры к этому корпусу пристыковываются отдельно расположенные колёса.



Завожу пустышку внутри корпуса, внутри которой будут находится точки, задающие позиции лап. Лапы размещаются просто в игровом мире. Само движение оставил реализованным через колёса, но их визуальное представление отключено.


Unigine для редактирования кода запускает внешнюю среду

Код:

using System;//стандартная "шапка"using System.Collections;using System.Collections.Generic;using Unigine;//уникальный идентификатор компонента, генерируемый при создании скрипта[Component(PropertyGuid = "5a8dd6f85781adf7567432eae578c5414581ddac")]public class theLegBehavior : Component{[ShowInEditor][Parameter(Tooltip = "CenterSpider")]//указатель на центр паукаprivate Node spiderCenter = null;[ShowInEditor][Parameter(Tooltip = "Target Leg Point")]//указатель на точку крепленияprivate Node legPoint = null;//переменные для вычислений дистанций по осямprivate float x_dis= 0.0f;private float z_dis= 0.0f;private float ifps;//переменная для дельтатаймprivate float time_lag = -1.0f;//таймер-флагprivate void Init()//инициализация{node.Enabled = false;//скрыть лапуLegPlace();//вызвать перестановку лапы}private void Update()//основной цикл{ifps = Game.IFps;//сохранить дельтатаймif (time_lag>=0.0f){//далее уже знакомая конструкцияtime_lag += 1.0f*ifps;if (time_lag>=0.6f) {time_lag = -1.0f;LegPlace();}}else{x_dis = MathLib.Abs(legPoint.WorldPosition.x - node.WorldPosition.x);z_dis = MathLib.Abs(legPoint.WorldPosition.z - node.WorldPosition.z);            if (x_dis + z_dis > 0.8f){time_lag = 0.0f;}}}        //функция перерасчёта положения лапы. здесь уже финальный показ лапы встроен внутрь функции. также тут происходит единичный разворот в сторону центра паука. а постоянный разворот считается вне этого скрипта, а в отдельном, наброшенном на лапу скрипте, хотя я по сути уже это вынес оттуда и можно включить в Update этого скрипта.private void LegPlace(){node.Enabled = false;vec3 targetDirection = vec3.ZERO;targetDirection = (legPoint.WorldPosition - node.WorldPosition);quat targetRot = new quat(MathLib.LookAt(vec3.ZERO, targetDirection, vec3.UP, MathLib.AXIS.Y));quat delta = MathLib.Inverse(targetRot);delta.z = 0;delta.Normalize();node.WorldPosition = legPoint.WorldPosition;        targetDirection = (spiderCenter.WorldPosition - node.WorldPosition);node.SetWorldDirection(targetDirection, vec3.UP, MathLib.AXIS.Y);node.Enabled = true;}}



Видеонарезка паучьего теста в Unigine



PlayCanvas


PlayCanvas игровой движок под webGL, использующий javascript. Недавно начал в нём разбираться. Напоминает нечто среднее между Unity и Godot, но с разработкой онлайн редактор открывается в браузере.

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

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



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



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


В playcanvas редактор кода запускается вновой вкладке браузера

Код:
var TheLegBehavior = pc.createScript('theLegBehavior');//ссылка на центр паукаTheLegBehavior.attributes.add('N_spiderCenter', { type: 'entity' });//ссылка на точку постановки этой лапкиTheLegBehavior.attributes.add('N_legPoint', { type: 'entity' });this.x_dis = 0.0;this.z_dis = 0.0;this.time_lag = -1.0;// initialize code called once per entityTheLegBehavior.prototype.initialize = function() {        };// update code called every frameTheLegBehavior.prototype.update = function(dt) {    if (this.N_spiderCenter) {        this.entity.lookAt(this.N_spiderCenter.getPosition());    }};// постапдейтTheLegBehavior.prototype.postUpdate = function(dt) {    //    if (this.N_spiderCenter) {//        this.entity.lookAt(this.N_spiderCenter.getPosition());//    }    if (time_lag>=0.0){        time_lag+=1.0*dt;        if (time_lag>=0.06){            time_lag=-1.0;            this.LegUpdate();        }            } else {                x_dis = Math.abs(this.entity.getPosition().x-this.N_legPoint.getPosition().x);        z_dis = Math.abs(this.entity.getPosition().z-this.N_legPoint.getPosition().z);                if ((x_dis+z_dis)>3.0){         time_lag=0.0;        }                    }};TheLegBehavior.prototype.LegUpdate = function() {        if (this.N_legPoint) {        this.entity.setPosition(this.N_legPoint.getPosition());    }    //    if (this.N_spiderCenter.enabled === false) {//        this.entity.enabled = false;//    }//    if (this.N_spiderCenter.enabled === true) {//        this.entity.enabled = true;//    }    };


В целом, пока получилась заготовка паучка с четырьмя лапками и не совсем оптимальными расчётами.

Потестить получившегося на текущий момент кадавра можно здесь:
https://playcanv.as/p/rOebDLem/

Я лично пробовал запускать на не слишком мощном смартфоне через Chrome и Dolphin, графика становится похожей на PsOne и вычисления для некоторых лап не работают, пока на экране виден уровень, у края карты лапы проявляются. На слабом ноутбуке наблюдались очень сильные тормоза в Chrome, но в Firefox всё катается нормально. На ноутбуке с дискретной видеокартой и на стационарнике летает в обоих этих браузерах.

На ПК, в отличие от смартфонов в этой демке работает прыжок (по кнопке пробел), заготовка стрейфа (Q и E) и перезагрузка уровня (на R).

Итог



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

Механики ловушек и механизмов в Godot Engine

18.10.2020 20:21:07 | Автор: admin
Здравствуйте. Эта статья ответвление от цикла статей по механикам для реализации платформеров, так как здесь я буду рассказывать о создании ловушек и механизмов, которые могут быть использованы не только в платформерах.

Но сразу скажу о некоторых моментах, которые должны быть реализованы в персонаже:
  1. Перемещение. Без него подойти к механизму или ловушке банально не выйдет
  2. Взаимодействие. Для активации объекта нужно вызвать определенный метод в объекте, если он существует. В данном цикле будет предположено, что это interact()
  3. Умение умирать. Персонаж должен умирать, чтобы в ловушках был смысл. Если персонаж не умеет умирать ловушки его не убьют, как бы глупо это не звучало

Итак я расскажу в этом коротком уроке о том как реализовать шипы, да односторонние порталы.

Шипы


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

Некоторые пояснения: Spikes2D StaticBody2D, Timer нужен для периодического переключения состояния шипов, его сигнал timeout присоедините к скрипту. Сигнал body_entered от Area2D присоединить к скрипту в корне сцены.
А теперь приступим к написанию скрипта для этой сцены.
tool # чтобы сделать изменение состояния динамическимextends StaticBody2D# Основные 2 параметра.export (bool) var spikes_showed: bool = falseexport (bool) var timer_enabled: bool = false # Дублируем первые две переменные для того, чтобы динамически изменять.var spikes_showed_previous: bool = falsevar timer_enabled_previous: bool = falsefunc _process(_delta: float) -> void:if self.timer_enabled and $Timer.is_stopped():$Timer.start() # Если self.timer_enabled == true и $Timer остановлен - запустить таймерif self.spikes_showed != self.spikes_showed_previous:if self.spikes_showed:$AnimatedSprite.play("show") # Нужны 2 анимации.else:$AnimatedSprite.play("hide")self.spikes_showed_previous = self.spikes_showedfunc _on_Area2D_body_entered(body: Node) -> void:if body.name.ends_with("Actor") and self.spikes_showed:body.dead()func _on_Timer_timeout() -> void:if self.timer_enabled:self.spikes_showed = !self.spikes_showed$Timer.start()

Этого хватит чтобы убить игрока по соприкосновению с выдвигающимися шипами.

Телепорт


Сегодня(18 октября 2020 года) я потрудился придумать телепорт во второй раз. Но в отличии от первой попытки я не создавал отдельного Position2D в сцене телепорта, а просто добавил NodePath как экспортируемую переменную, чтобы указать объект к позиции которого нужно телепортироваться. Для того чтобы всё работало как надо достаточно создать следующую структуру:

Мне было в лом обрезать фото, да так думаю будет более наглядно показано, что как выглядит
И вот скрипт отдельно, чтобы не создавать трудности при перепечатывании:
extends Area2D # В этот раз мне было лень писать данный скрипт как toolexport (bool) var portal_opened: bool = false # Указывает - открыт ли портал, что можно понять из названияexport (NodePath) var destination_node: NodePath # Узел к которому этот объект будет телепортировать. Должен иметь параметр position чтобы игра не сломалась.func interact(portal_user: Node):if portal_user.name.ends_with("Actor"):if (destination_node != null): # Если выбрали нужный узел в редактореportal_user.global_position = get_node(destination_node).global_position # переместить игрока в глобальное положение того узла, на который указывает NodePathfunc _on_AnimatedSprite_animation_finished():if $AnimatedSprite.animation == "portal_open":$AnimatedSprite.play("default")  # Если анимация открытия портала закончилась - включить анимацию default. Но можно без этой функции просто нарисовать спрайт и заставить его быть включенным всегда в цикле.

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

Заключение


Для начала этого под цикла статей думаю данной выпуска хватит. В 4 выпуске основной серии статей я расскажу о том как научить персонажа взаимодействовать с порталами, ключами и дверьми. И подкиньте в комментариях идеи механик, если вам интересно узнать их реализацию. Спасибо за прочтение и до следующих выпусков.
Подробнее..

Механики для реализации платформера на Godot engine. 4 часть

20.10.2020 18:21:10 | Автор: admin
Здравствуйте снова. В этом выпуске я расскажу о том, как исправил механику карабканья, показанную во втором выпуске, покажу механику взаимодействия, для создания интерактива. Это по-прежнему будет доработка персонажа, так что окружающий мир будет подвергнут минимальным изменениям, но главный герой будет очень сильно улучшен. Правда до дерева навыков ещё далеко, поэтому оставайтесь на связи и я покажу как можно реализовать всё, что придёт нам в голову.

Предыдущие статьи:

Улучшение системы карабканья из второго выпуска и другое

В общем покажу весь код своего персонажа и постараюсь прокомментировать его наиболее понятно.
# В этот раз будет очень много кода, потому что я не представляю себе все эти системы по отдельности.extends KinematicBody2Dsignal timer_ended # Сигнал о отключении таймера в _processconst UP_VECTOR: Vector2 = Vector2(0, -1) # Направление вверхconst GRAVITY: int = 40# Скорость паденияconst MOVE_SPEED: int = 100# Скорость перемещенияconst JUMP_POWER: int = 480# Сила прыжкаconst CLIMB_SPEED: int = 40# Скорость карабканьяconst WALL_JUMP_SPEED: int = 80# Скорость прыжка от стеныenum States {ON_FLOOR, ON_WALL} # Как я выяснил, этому скрипту нужно только 2 состоянияonready var ray_cast: Object = $RayCast2D # Для реализации взаимодействия с другими объектами. Будет пояснён позжеvar velocity: Vector2 = Vector2.ZERO # Ускорение.var walls: Array = [false, false, false] # Для определения стен. Стена слева, стена сверху, стена справа.var timer_enabled: bool = false# Отвечает за включение таймераvar climbing: bool = false# Поднимаемся мы по стене, или просто падаем вдоль неёvar is_wall_jump: bool = false# Прыгаем ли мы от стены, или нетvar is_double_jump: bool = true # Двойной ли прыжокvar right_pressed: float = 0# Трансляция силы нажатия на стрелки влево и вправо, что позволяет подменить значенияvar left_pressed: float = 0var timer: float = 0# Таймерvar prev_direction: float = 0# Предыдущее направление. Нужно для того чтобы анимация бездействия воспроизводилась в обоих направленияхvar direction: float = 0# Текущее направление движения.var keys: int = 0 # Количество ключей. Нужно для открытия дверей, соответственноvar current_state: int = States.ON_FLOOR# Текущее состояние персонажаfunc _ready():ray_cast.add_exception($WallLeft) # говорит что не нужно обрабатывать лучу ray_castray_cast.add_exception($WallRight)ray_cast.add_exception(self)func _process(_delta: float) -> void: # метод _processif timer > 0 or timer_enabled:timer -= _delta# Уменьшаем таймер на _deltaif timer <= 0 and timer_enabled:timer_enabled = falsetimer = 0# Сбрасываем значение и выключаем таймерemit_signal("timer_ended") # Испускаем сигнал таймера.if self.direction != 0:self.ray_cast.cast_to *= -1self.prev_direction = self.direction# обновляем предыдущее направление если текущее не равно 0func _physics_process(_delta: float) -> void:self.control_character()self.pause_opened()# Вызываем для проверки - открыта ли паузаif (!self.climbing):# Если не карабкаемся, то проверяемif (!self.is_wall_jump): # Если прыжок от стены то увеличиваем self.velocity.y на гравитациюself.velocity.y += GRAVITYelse:# Иначе падаем в 4 раза медленнееself.velocity.y += float(GRAVITY) / 4self.velocity = self.move_and_slide(self.velocity, UP_VECTOR) # Обновить self.velocity из текущего состоянияfunc check_states() -> void:if self.is_on_floor():self.current_state = States.ON_FLOORis_double_jump = trueelif self.is_on_wall():self.current_state = States.ON_WALLis_double_jump = trueelif self.is_on_floor() and self.is_on_wall():self.current_state = States.ON_WALLfunc fall() -> void:self.velocity.y += GRAVITYfunc update_controls(): # Обновляем информации о нажатиях на кнопки "влево" и "вправо" if !is_wall_jump: # Если не прыгаем от стены сейчас - обновляемself.left_pressed = Input.get_action_strength("ui_left")self.right_pressed = Input.get_action_strength("ui_right")func control_character() -> void:# Об этом я уже рассказывалcheck_states()# Проверить состоянияupdate_controls()# Обновить данные о нажатии на стрелки влево и вправоself.interact_with()# Взаимодействие с другими объектами+match current_state:States.ON_WALL:self.climb()self.move()if !climbing:self.jump()self.fall()self.wall_jump()States.IN_AIR:self.jump()self.move()self.fall()States.ON_FLOOR:self.jump()self.move()func climb():if (walls[0] or walls[2]):# Если стена слева или справа - self.climbing = нажато ли событие "ui_climb". Тут вам самим нужно создать событие self.climbing = Input.is_action_pressed("ui_climb")else:# Иначе просто не карабкаемсяself.climbing = falsefunc climb_up() -> void:# Ползем вверх по стенеself.velocity.y = (CLIMB_SPEED)func climb_down() -> void:# ползем вниз по стенеself.velocity.y = (-CLIMB_SPEED)func move() -> void: # Перемещение. Я его доделал, чтобы по левой стенеself.direction = self.right_pressed - self.left_pressedif (self.climbing and !self.is_wall_jump):if self.walls[0]:# Если левая стенаif direction > 0:# Если движемся вправо - карабкаемся вверхclimb_up()elif direction < 0:# Иначе если движемся влево - спускаемся внизclimb_down()else:# Иначе никак не двигаемся по вертикалиself.velocity.y = 0elif self.walls[2]:# Почти то же самое что с движением по левой стене, только направления местами поменялif direction < 0:climb_up()elif direction > 0:climb_down()else:self.velocity.y = 0#else:# Я думал что это будет нужно, но видимо это осталось лишним#self.velocity.y = 0else: # Иначе если не карабкаемся по стене и от неё не прыгаем просто передвигаемсяself.velocity.x = self.direction * float(MOVE_SPEED) * (1 + (float(self.is_wall_jump) / 2))if !(climbing): # Анимацииif direction == 0:$AnimatedSprite.flip_h = (-self.prev_direction >= 0)$AnimatedSprite.play("idle")else:$AnimatedSprite.flip_h = direction < 0$AnimatedSprite.play("run")returnfunc jump() -> void: # Совершенно никаких изменений со второго выпуска в прыжкеif Input.is_action_just_pressed("ui_accept"):if is_on_floor():self.velocity.y = -JUMP_POWERif !is_on_floor() and is_double_jump:is_double_jump = falseself.velocity.y = -JUMP_POWERfunc wall_jump() -> void:if Input.is_action_just_pressed("ui_accept") and Input.is_action_pressed("ui_climb"):self.is_wall_jump = trueself.velocity.y = -JUMP_POWERif walls[0]:self.timer = 0.3self.timer_enabled = trueself.right_pressed = 1# Это приравнивание как я понял вынужденная мера из-за слишком простого механизма перемещенияyield(self, "timer_ended")# Подождать сигнал таймераself.right_pressed = Input.get_action_strength("ui_right") # Сбросить перемещение влево elif walls[2]:self.timer = 0.3self.timer_enabled = trueself.left_pressed = 1# Это приравнивание как я понял вынужденная мера из-за слишком простого механизма перемещенияyield(self, "timer_ended")self.left_pressed = Input.get_action_strength("ui_left")# Сбросить перемещение вправо self.is_wall_jump = false # Перестаём прыгать от стеныfunc interact_with() -> void: # Метод взаимодействияif Input.is_action_pressed("ui_use"):# Если нужная кнопка нажатаvar coll: Object = self.ray_cast.get_collider()# Определяем что столкнулосьif coll:# И если это не nullif coll.has_method("open"):# Проверяем, дверь это или объект взаимодействияuse_key(coll)elif coll.has_method("interact"):use_object(coll)func use_object(collider: Object) -> void:# Используй объектcollider.interact(self)# В дополнительном уроке так активировались порталыfunc use_key(collider: Object) -> void:# Метод открывает все двери.if self.keys > 0:# Если ключи естьcollider.open()# Открой объектself.keys -= 1# И убери ключ из инвентаря за ненадобностьюfunc key_picked_up():self.keys += 1func _on_WallRight_body_entered(_body):# Я уже рассказывал об этих определителях стен.if (_body.name != self.name):# Если с ними что-то столкнулось - они изменят соответствующуюself.walls[2] = true# переменную в массиве walls на true или false.func _on_WallRight_body_exited(_body):# self.walls[2] = false#func _on_WallLeft_body_entered(_body):#if (_body.name != self.name):#self.walls[0] = true#func _on_WallLeft_body_exited(_body):#self.walls[0] = false#func dead():# $Particles2D.emitting = true # Если вы добавили частицы крови - можете убрать комментарийLevelMgr.goto_scene("res://scenes/dead_screen/dead_screen.tscn") # Переход на экран смерти. Сделать чтобы отлет частиц был виден пока не придумал какfunc pause_opened(): # Открывает окно паузыif Input.is_action_just_pressed("ui_cancel"): # Если соответствующая кнопка нажата$PositionResetter/WindowDialog.popup_centered()

Заключение

На данный момент это самый актуальный код персонажа, который я только создал. Из-за того, что код полностью готов, мне нечего добавить к нему отдельно. А так как я вынес уроки по ловушкам в отдельный цикл, то мне также нечего сказать про примеры использования системы взаимодействий. Также мне стало интересно, что вы предпочли бы узнать в следующей части и представил ниже опрос составленный из пришедших мне в голову идей для механик. Спасибо за прочтение и до следующих публикаций.
Подробнее..

Механики для реализации платформера на Godot engine. 5 часть

15.11.2020 16:06:22 | Автор: admin
Здравствуйте. В предыдущем опросе читатели выбрали следующие пункты на момент создания данной статьи: система характеристик оружия, система здоровья персонажа. А вот дерево навыков я так и не сообразил как правильно реализовать Точнее как создать интерфейс, чтобы отображать и управлять деревом.Предыдущие части:

Система здоровья

Ну начнём пожалуй с системы здоровья персонажа. Я решил создать отдельный файл для всего необходимого. Он назван health_system.gd и лежит в папке скриптов. Важно: это не синглтон, а просто скрипт. Дальше вы поймёте почему.
# Теперь я буду использовать синтаксис javascript. В нём хоть var подсвечивается.extends Object # Расширяем базовый объектclass_name Health # Подписываем класс как Health и после он доступен из любого места игры как тот-же Node, или Node2D.signal death # Добавляем сигнал "death", чтобы испускать его, когда персонаж умирает. Этим персонажем может быть не обязательно даже игрок, или вообще что-то живое.var health: int = 100 setget set_health, get_health # Здоровье и setget для его измененияvar max_health: int = 100# верхний иvar min_health: int = 0# нижний предел здоровьяfunc set_health(new_health: int) -> void: # Функция для того, чтобы ставить new_health здоровья игроку при вызове, что будет не больше или меньше чем можно# warning-ignore:narrowing_conversion # О том что clamp возвращает целое значение, отрезая дробную часть.health = clamp(new_health, min_health, max_health) # Ограничиваем здоровьеif health == min_health: # А если здоровья - заданный минимум - мы умираем, меньше не будет.emit_signal("death") # Испускаем сигнал о том что нечто с этим здоровьем умерлоreturnfunc get_health() -> int:return health # получаем текущее здоровье. Если возвращать константу - игрок будет бессмертным, но константа должна быть больше максимального получаемого урона в игре, иначе мы всё равно умрём.func set_min_max_health(new_max: int = 100, new_min: int = 0) -> void: # Задача верхнего и нижнего предела здоровья. Тут поддерживается отрицательный минимум, что позволит создать эффект грани жизни и смерти, что поднимет адреналин игрока, как было в первой части того же Assasin's creed, что улучшало ощущение хорошей игры.self.max_health = new_maxself.min_health = new_minreturnfunc damaged(dmg: int = 0) -> void: # Получение урона.self.health -= dmg # После некоторых разбирательств я выяснил следующее: set_health() вызывается вместо стандартного "=" и подобных, что позволяет не писать логику смерти в получении урона.return
Чтобы его добавить к персонажу нужно всего-лишь добавить к переменным
# ...var health: Health = Health.new()# ...# И подключаем сигнал смерти в _readyfunc _ready():health.connect("death", self, "dead") #сигнал "смерть" к self.dead(). Смерть я писал в предыдущих выпусках, а если точнее в 3-ей части.
И теперь игрок имеет здоровье. Правда нас всё по-прежнему убивает с 1 удара, потому что я не рассказал об оружии, что будет наносить N урона, а не просто убивать.

Система оружия

А если точнее базовое оружие, которое можно прикреплять к разным объектам и брать данные от него.Ближе к делу, поэтому начну сразу с кода.
extends Nodeclass_name Weapon # Подписываем как Weapon, чтобы можно было прикреплятьenum WeaponTypes {MILLITARY, RANGED, BLOCKER} # Ближний бой, дальний и блокировщикexport (NodePath) var weapon_owner_node_path: NodePath # Путь к владельцуexport (int) var damage: int = 10 # уронexport (int) var critical_damage: int = 20 # критический уронexport (float) var critical_damage_chance = 0.2 # шанс критического урона. Бросаем кубик, если число меньше этого - атакуем критическим урономexport (WeaponTypes) var weapon_type: int = 0 # Тип оружия из перечисленияexport (int) var max_damage_distance: int = 0x20 # Дальность атаки чтобы нанести максимальный урон, если больше - урон будет меньше. В разработке. По умолчанию 32 пикселяvar weapon_owner: Node # Заготовка weapon_owner, чтобы в _ready задать из NodePath, что позволит немного оптимизировать множественные вызовыvar use: FuncRef func _ready() -> void:weapon_owner = WeaponController.get_node_(weapon_owner_node_path)match weapon_type: # ставим в use нужный FuncRef, что вызывается с помощью use.call_func() аргументы в скобки.WeaponTypes.MILLITARY:use = funcref(self, "attack") # Обычная атакаWeaponTypes.RANGED:use = funcref(self, "ranged_attack") # Дальняя атакаWeaponTypes.BLOCKER:use = funcref(self, "block") # Блок атаки врагаfunc attack(enemy: Entity) -> bool:print("attack")enemy.health.damaged(self.damage)return true # Удачность нанесения уронаfunc ranged_attack(enemy: Entity) -> bool:var dmg: float = damageif weapon_owner.global_position.distance_to(enemy.global_position) > max_damage_distance:dmg -= damage / 4enemy.health.damaged(round(dmg))return truefunc block(enemy: Entity) -> bool:print(randi() > critical_damage_chance) # В разработке. Нужно чтобы гасить урон от атак врагов, но я до конца не придумал как реализовать его.return true
Добавляем этот узел к объекту, к примеру к пуле:
extends Entity # Тут я не буду рассказывать обо всём, потому что этот урок о том как прикреплять скрипты здоровья и сцены оружия, а не о создании пушек и других ловушек. О пушке расскажу в следующем уроке о механизмах и ловушках.var direction = Vector2(-1, 0) setget set_directionconst SPEED: int = 40onready var weapon: Weapon = $Weapon # Задаём оружиеfunc _ready():health.connect("death", self, "dead") # health есть уже в Entity, но это единственное что есть в нём.func _physics_process(delta):var motion: Vector2 = direction * SPEED * deltavar collision = move_and_collide(motion, false)if collision != null and collision.collider != null:var coll = collision.colliderif coll.name == "TileMap":self.dead()else:weapon.use.call_func(coll) # Атакуем объект со здоровьемself.dead()print(coll.health.get_health())func set_direction(vec: Vector2) -> void:direction = vecfunc dead():self.queue_free() # Метод смерти просто освобождает этот объект через queue_free()
Вот как выглядит сцена пули:Надеюсь этой картинки хватит чтобы понять как работает сцена Weapon.

Заключение

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

Принцип работы EditorScript

21.06.2021 00:15:47 | Автор: admin

Всем привет. С вами я, stalker320, и сегодня я хотел бы рассказать про написание служебных скриптов, запускающихся в редакторе. Всем желающим прочесть - добро пожаловать под кат


EditorScript - это такой скрипт, который можно запустить по нажатию комбинации Ctrl+Shift+X прямо из движка. Он может исполнять различные служебные функции. К примеру создать необходимую структуру каталогов в проекте.

toolextends EditorScriptvar folders: PoolStringArray = [# Наши папки к созданию"res://assets/textures/",# Их определяет разработчик"res://assets/fonts/",# Просто напишите сюда все конечные пути, что хотите создать"res://resources/","res://addons/","res://scenes/","res://scripts/singletons/","res://scripts/resources/","res://scripts/editor_scripts/"]var placeholder: Resource = load("res://placeholder.tres")# Ресурс-пустышка для сохранения структуры файлов. По желаниюfunc _run() -> void: # Входная функция этого скриптаvar dir = Directory.new()for folder in folders: # проход по списку папокif !dir.dir_exists(folder):var err = OKerr = dir.make_dir_recursive(folder)if err != OK:prints("Error", err)returnelse:if !dir.file_exists(folder.plus_file("placeholder.tres")):# Создаём файл плейсхолдера для гита если не существуетerr = ResourceSaver.save(folder.plus_file("placeholder.tres"), placeholder)if err != OK:prints("Error", err) returnelse:# Говорим что создали (не обязательно)prints("Making", folder)prints("Making", folder.plus_file("placeholder.tres"))print("Successful. Structure created or already exists.")return

К сожалению я освоил их не очень хорошо, поэтому создать генератор какого-нибудь API для дальнейшего моддинга игры фанатами пока не вышло. (Хотя создать необходимую структуру файлов вышло, а это значит, что кто-либо другой может расширить функционал через словарь и создавать необходимые скрипты). Кроме того, чтобы создавать нужную структуру файлов прямо в редакторе, через метод get_scene() можно взаимодействовать и с самой сценой (Достойные примеры скриптов и сцен, к сожалению, отсутствуют).

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

Спасибо за внимание. Не думаю, что буду писать статьи в дальнейшем очень часто, но у меня есть идеи, о чём можно написать.

Подробнее..

Recovery mode Частицы в Godot 3, сглаживание маленьких частиц, и система отпечатков на шейдерах

04.09.2020 10:14:53 | Автор: admin

Использование разных частиц в Godot 3. Без использования эффектов постобработки.

Ссылки:

Запустить WebGL2 и скачать другие версии ссылка на itch.io.

Исходный код на github.

Статья разбита на разделы:

  1. Сглаживание мелких частиц, разные способы.

  2. Много шаров - частиц без потери производительности. И 3d-проекция в шейдере.

  3. Система отпечатков Screen space decals.

  4. Прочее.


1. Сглаживание частиц

Использую плоский объект размером в 2 или 4 раза больше чем отображаемая графика, самая критичная графика по центру текстуры, выглядит так, слева в 2 раза больше справа в 4 раза:

И черное становится прозрачным.

Идея в том чтобы иметь хотяб 1 пиксель вокруг основной линии при даже самых маленьких углах поворота и удалении от объекта. Я использую 4-х кратный отступ на самом дальнем объекте:

Код шейдера particlelineAAbase.shader включает три версии для теста - без фильтрации, с процедурной фильтрацией используя dFd* функцию, и используя текстуру, порядок слева на право:


2. Проекция трехмерных частиц на плоскость

Использую intersection и projection логику в фрагментном шейдере.

Идея в том что - отобразить 1000 трехмерных шаров-частиц для видеокарты(GPU) стоит слишком дорого, и 1000 трехмерных шаров-частиц у меня уже нагружают GPU на 100%. Когда рендеринг 1000 кругов(не шаров) использует меньше 25% GPU.

И поворачивая плоский круг или квадрат относительно камеры можно иметь полноценный шар-частицу:

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

Исходный код шейдера шара particle_cloud_base.shader и код шейдера куба и линии.

Для шара оригинал кода взят из iquilezles.org.


3. Система отпечатков Screen space decals

Использую код шейдера из Screen-Space-Decals, мои модификации:

  1. Поворот относительно позиции, по нормали к поверхности при установке.

  2. Затухание на краях куба с dacal.

  3. Логика Material-ID для того чтобы Decals оставались только на выбранной поверхности не затрагивая другие объекты рядом.

Работает так:

Про логику Material-ID:

В Godot нет возможности записывать дополнительную информацию в основном render-pass, информацию о типе материала. Поэтому я использую отдельный Viewport в котором есть только статическая сцена:

Красный канал для material-ID синий для значения глубины(depth). Глубина(depth) используется чтобы вырезать объекты. Логика выглядит так, на скриншоте частицы и персонаж не существуют на Material-ID Viewport и вырезаются по depth:

Использование отдельного Viewport это очевидный overhead, в качестве оптимизации можно уменьшить разрешение, это можно тестировать в этой версии - в меню Debug (мышку в левый верхний угол для показа меню) и установить множитель для теста, от 0.25 до 1.


4. Прочее

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

В таком случае нужна всего одна плоскость, минимум треугольников, чтоб генерировать частицы любой длины и они будут выглядеть объемными.

В качестве оригинала такой логики я использовал этот шейдер Garden Fireworks - @P_Malin.

Шейдер для картинки загрузки - мой старый шейдер.

Анимация форм функций - моя старая демка. (старая версия так и не заработала в WebGL,по прежнему работает только в браузере на Linux)

Логика - 360 линий поворачиваются на 1 градус каждая и вращаются вокруг центральной оси, каждая линия имеет форму одной из функций. Выглядит также как в оригинальной демке:

Баги:

В ходе написания этих шейдеров нашел несколько багов в драйверах Nvidia:

Краш компилятора, баг с матрицами в Vetrex шейдере, также запуская некоторые шейдеры в Vulkan там тоже есть баг в драйвере.

Лицензия и используемые материалы:

  1. Все 3d модели взяты со sketchfab и имеют CC-non comercial лицензию.

  2. Музыка взята с сайта patrickdearteaga.com

  3. Весь мой код и все шейдеры имеют лицензию MIT license.

Ссылка на список используемых ресурсов.

Спасибо за чтение этой статьи.

Подробнее..

Прямоугольные тайловые миры

04.05.2021 06:11:43 | Автор: admin

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

Огромное множество игр на самом деле содержат тайлы - так просто проще представлять игровой мир. Такая упорядоченность помогает геймдизайнерам строить игровые механики, упрощает жизнь художников и делает код программистов понятнее. Самих видов тайлов тоже огромное количество - сегодня поговорим о прямоугольных и изометрических.

Не стоит воспринимать все сказанное здесь как научную истину, многие вещи выведены мной, так что не стесняйтесь исправлять, ругать и дополнять меня в комментариях. Все примеры сделаны на движке Godot Engine v. 3.2.3 с использованием его встроенного языка.

Думаю в целом язык понятен, но вот ссылки на некоторые функции из документации:

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

Прямоугольные сетки

Начнем с чего попроще - прямоугольных сеток. Такой тип представления игровой логики наверное максимально распространен в настольных играх, как пример - шахматы. В них фигуры ходят и бьют строго по клеткам на ограниченной размером 8x8 ячеек доске (ну мало ли кто то не знает). Не удивительно что из-за такой простоты и вариативности эта дисциплина быстро распространилась на компьютеры. Давайте же смотреть как это все устроено.

Система координат

Для понимания игрового мира необходима система коорднат. Обычно она состоит из двух осей (для двумерного мира) - X и Y. Внутри самой сетки направим оси как экранные - ось X вправо, ось Y вниз. Единичные отрезки представляют ячейки. Для связи сеточной системы отсчета с экранной необходимы базисные векторы, которые будут направлены вдоль осей сетки. Длины таких векторов будут равны размерам ячейки в пикселях, а начало системы отсчета, аналогично экранной, поместим в левый верхний угол:

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

const cell_width = 48 # width of cell in pixelsconst cell_height = 32 # height of cell in pixels# Basis vectorsvar srv = Vector2(cell_width, 0) # screen-right-vectorvar sdv = Vector2(0, cell_height) # screen-down-vector

Преобразование координат

С преобразованием ячейки в пиксель проблем возникнуть не должно. Если клетка имеет координаты {x; y}, это значит, что на экране она находится на x горизонтальных и на y вертикальных базисов от начала координат:

func cell2pixel(cell:Vector2) -> Vector2:return srv*cell.x + sdv*cell.y

После такого преобразования получаем левый верхний угол ячейки. Для нахождения ее центра просто прибавляем по половинке базисных векторов:

func cell2pixel_center(cell:Vector2) -> Vector2: # To cell centerreturn cell2pixel(cell)+srv/2+sdv/2 # cell2pixel returns left-top corner

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

А как из пикселя получить ячейку? Если говорить простым языком, точка лежит в ячейке, пока она находится в пределах ее длины и высоты. Это означает, что для смещения к следующей ячейке необходимо переместится на ее размер на экране. Тогда точные координаты в сеточной системе отсчета можно найти через деление каждой координаты пикселя на соответствующий ей размер ячейки. В результате деления получится дробное число, вещественная часть которого показывает смещение пикселя внутри клетки относительно ее верхнего угла. Т.к. нас интересует только ячейка, округлим к ближайшему меньшему целому. Это важно, ведь при маленьких дробных отрицательных значениях (по модулю <1) приведение к int все равно покажет 0, хотя это уже отрицательная часть:

func pixel2cell(pixel:Vector2) -> Vector2:var x = floor(pixel.x/cell_width)var y = floor(pixel.y/cell_height)return Vector2(x, y)

Однако такие преобразования можно описать более точно, с помощью математики. Мы имеем базис из векторов srv{cell_width; 0} и sdv{0; cell_height}. Представив его в виде матрицы запишем преобразование координат ячейки в координаты пикселя как произведение матрицы и вектора:

А для нахождения отсюда pixel нужно обратить матрицу:

Раскрыв выражения получим тоже самое, что и ранее. Я согласен с тем что этот подход тут неуместен, однако далее вы увидите всю его настояющую мощь.

Как видим, все прекрасно работает:

К сожалению, сетка и этот прекрасный желтый прямоугольник появляются не по щучьему велению, поэтому рисуем это тоже сами.

Рисование сетки

Алгоритм рисования не отличается от разлиновывания чистого листа - просто рисуем вертикальные и горизонтальные линии с одинаковым интервалом. Для рисования сетки необходимо знать ее размеры:

const map_width = 5const map_height = 5

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

func draw_grid(surf:RID, color:Color, width=1.0, antialiasing=false) -> void:for i in range(map_width+1):VisualServer.canvas_item_add_line(surf, cell2pixel(Vector2(i, 0)), cell2pixel(Vector2(i, map_height)), color, width, antialiasing)for i in range(map_height+1):VisualServer.canvas_item_add_line(surf, cell2pixel(Vector2(0, i)), cell2pixel(Vector2(map_width, i)), color, width, antialiasing)

Получаем обычную прямоугольную сетку, то что нужно:

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

func draw_open_grid(surf:RID, color:Color, width=1.0, antialiasing=false) -> void:for i in range(1, map_width):VisualServer.canvas_item_add_line(surf, cell2pixel(Vector2(i, 0)), cell2pixel(Vector2(i, map_height)), color, width, antialiasing)for i in range(1, map_height):VisualServer.canvas_item_add_line(surf, cell2pixel(Vector2(0, i)), cell2pixel(Vector2(map_width, i)), color, width, antialiasing)

Рисование ячейки

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

func draw_cell(cell:Vector2, surf:RID, color:Color, width=1.0, antialiasing=false):var points = PoolVector2Array([cell2pixel(cell), # Левый верхний уголcell2pixel(cell)+srv, # Прибавили правый базис, правый верхний уголcell2pixel(cell)+srv+sdv, # Прибавили оба базиса, правый нижний уголcell2pixel(cell)+sdv, # Прибавили нижний базис, левый нижний уголcell2pixel(cell) # Замыкаем цепочку])VisualServer.canvas_item_add_polyline(surf, points, [color], width, antialiasing) 

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

func fill_cell(cell:Vector2, surf:RID, color:Color) -> void:VisualServer.canvas_item_add_rect(surf, Rect2(cell2pixel(cell), Vector2(cw, ch)), color)

Выглядит это как то так:

Изометрические сетки

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

Казаки

А сейчас же изометрия используется в основном только инди студиями, да и спрайты выглядят гораздо проще - обычно это несложный пиксель-арт. Одним из ярчайших примеров является Into the Breach, обладатель нескольких наград и покоритель моего сердца своим шахмато-подобным геймплеем.

Into the Breach

Система координат

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

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

Координаты базисных векторов найти как проекции на экранные оси. Тогда при проецировании проекции будут ровно попадать в диагонали, ведь они параллельны экранным осям. Для синего (X) базиса это будут половина горизонтальной диагонали по X и половина вертикальной по Y. Для красного (Y) все тоже самое, только его проекция на экранную ось X будет отрицательной.

Зададим размер ячейки и два значения - cw как половина горизонтальной диагонали и ch как половина вертикальной. Как упоминалось ранее, сетка сжимается по вертикали вдвое, значит вертикальная диагональ, изначально равная горизонтальной, тоже уменьшилась вдвое:

const cell_size = 60# работать с int гораздо проще, поэтому убираем вещ. часть,# от этого сильно ничего не поменяется.cw = int(cell_size/2) # cell-widthch = int(cw/2) # cell-height

Через эти значения базисные векторы можно задать проще простого:

...right_basis = Vector2(cw, ch) # вектор по Xleft_basis = Vector2(-cw, ch) # вектор по Y

Преобразование координат

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

func cell2pixel(cell:Vector2) -> Vector2:return cell.x*right_basis + cell.y*left_basis

Для получения центра ячейки аналогично прямоугольной сетке прибавляем по половинке базисов:

func cell2pixel_center(cell:Vector2) -> Vector2:return cell2pixel(cell)+right_basis/2+left_basis/2

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

Тогда для получения отсюда ячейки cell обращаем матрицу и раскрываем выражение:

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

func pixel2cell(pixel:Vector2) -> Vector2:var x = pixel.x/cw + pixel.y/chvar y = pixel.y/ch - pixel.x/cwreturn Vector2(floor(x/2), floor(y/2))

Кто ничего не понял, просто пользуется готовой формулой. Она работает, честно. А вот как это выглядит:

Преобразование угла

Как найти угол между двумя ячейками понятно - находим соединяющий их вектор через разность, потом определяем его угол. Но могут возникнуть проблемы когда нам известен только угол. Например, корабль хочет выстрелить вправо, т.е. под 0 относительно себя. Такое направление указывает вдоль горизонтальной оси, но мы знаем, что изометрическая ось не особенно то и совпадает с экранной. Как перевести одно в другое мы уже выяснили, а если угол произвольный? Тут тоже пригодятся описанные выше преобразования. Представим угол через вектор с координатами {cos a; sin a} и прогоним его через ту же функцию, после чего найдем угол вектора с преобразованными координатами:

func iso2linear(angle:float) -> float:var x = cos(angle)var y = sin(angle)return cell2pixel(Vector2(x, y)).angle()

Точно также преобразуется экранный угол в изометрический, только тут используются формулы напрямую, т.к. готовая функция округляет результат, а при работе с такими маленькими величинами целая часть всегда будет равна нулю. Также я опустил деление на 2, ведь от него направление все равно не меняется:

func linear2iso(angle:float) -> float:var x = cos(angle)var y = sin(angle)var x1 = x/cw + y/chvar y1 = y/ch - x/cwreturn Vector2(x1, y1).angle()

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

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

Произвольное изометрическое искажение

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

var iso_scale = 2.0

Тогда единственное, что нужно поменять, это значение переменной ch:

...ch = int(cw/iso_scale)...

И все. Все предыдущие функции будут работать как прежде. Вот, например, соотношение 1 к 1.43:

Порядок рисования объектов

Вряд ли вы хотите увидеть в своей игре что такое:

Те блоки, которые ближе к нам, перекрываются дальними, поэтому создается впечатление что мы строим в высоту, хотя это не так. Если в простой прямоугольной сетке мы можем определять порядок отрисовки по Y координате, то здесь оба базиса смотрят вниз и определить по одному из них порядок рисования не выйдет. В изометрии мы можем сортировать объекты по диагоналям, потому как их ряды идут точно вниз. Каждая ячейка в ряду имеет одну и ту же сумму координат, поэтому именно эту сумму мы и будем использовать как z_index объекта (в godot эта переменная у двумерных объектов обозначает порядок их рендера. Чем этот индекс выше, тем позже рисуется объект):

При изменении позиции приравниваем z_index к pos.x+pos.y (pos - позиция объекта на сетке):

func set_pos(cell):pos = cell # сохраняем значение в свою локальную переменнуюposition = Grid.cell2pixel(pos) # ставим объект на экране в нужную точкуz_index = pos.x+pos.y # устанавливаем порядок отрисовки

Теперь все рисуется как надо и создается необходимая иллюзия объема:

Прямоугольные изометрические сетки

Квадратные сетки конечно здорово, но с ними как то не особо разбежишься. Вдруг мне захочется сделать игру про кораблики, которые вытянуты вдоль? Тогда использовать квадратные тайлы нерентабельно, остается много пустого места. Для такой игры как раз подойдут прямоугольные сетки.

Система координат

Система координат внутри сетки все остается прежней, потому как, напоминаю, мы меняем только вид на нее. Базисы точно также будут направлены вдоль изометрических осей, а вот для задания их координат необходимо повозиться. Дело в том, что у таких ячеек нет размеров, независящих от вертикальных преобразований, все стороны и диагонали будут менять свои размеры. Но мы же знаем, что при вертикальных преобразованиях точки не перемещаются по X, а это значит что проекции всех величин на эту ось тоже сохраняются. В качестве размеров можно взять просто длину и высоту, как в случае с обычными прямоугольными сетками. Это будут размеры в натуральную величину, т.е. при отсутствии изометрических искажений, и именно при таком виде можно и найти проекции. Без искажений угол между экранной горизонтальной осью и обеими изометрическими будет 45. Используя его, найдем проекции:

const cell_width = 93 # Real cell widthconst cell_height = 67 # Real cell heightpw = int(round(cell_width*cos(PI/4))) # Проекция длиныph = int(round(cell_height*cos(PI/4))) #  Проекция высоты

Тогда синий вектор будет иметь координаты {pw; pw}, а красный {-ph; ph}. Для создания изометрического искажения просто делим Y-компоненту каждого базиса на коэффицент этого искажения:

const iso_scale:float = 2.0...srd = Vector2(pw, pw/iso_scale) # screen-right-downsld = Vector2(-ph, ph/iso_scale) # screen-left-down

Преобразование координат

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

func cell2pixel(cell):return cell.x*srd+cell.y*sld

И с получением центра ничего не поменялось (да ладно):

func cell2pixel_center(cell): # To cell centerreturn cell2pixel(cell)+srd/2+sld/2

Да и для преобразования пикселя в ячейку проворачиваем тот же трюк:

Повторение мать учения

Выносим двойку и округляем:

func pixel2cell(pixel):var x = pixel.x/pw + iso_scale*pixel.y/pwvar y = iso_scale*pixel.y/ph-pixel.x/phreturn Vector2(floor(x/2), floor(y/2))

Ну и куда же без визуализации:

Рисование сетки

С рисованием тоже особо ничего не поменялось, просто рисуем линии в цикле от начала до края карты:

func draw_grid(surf, color, width=1.0, antialiasing=false):for i in range(map_width+1):VisualServer.canvas_item_add_line(surf, cell2pixel(Vector2(i, 0)), cell2pixel(Vector2(i, map_height)), color, width, antialiasing)for i in range(map_height+1):VisualServer.canvas_item_add_line(surf, cell2pixel(Vector2(0, i)), cell2pixel(Vector2(map_width, i)), color, width, antialiasing)

И рисование ячейки не тоже изменилось:

func draw_cell(cell, surf, color, width=1.0, antialiasing=false):var points = PoolVector2Array([cell2pixel(cell),cell2pixel(cell)+srd,cell2pixel(cell)+srd+sld,cell2pixel(cell)+sld,cell2pixel(cell),])VisualServer.canvas_item_add_polyline(surf, points, [color], width, antialiasing)

Для заливки ячейки нарисуем полигон, ограниченный четырмя вершинами тайла:

func fill_cell(cell, surf, color):var points = PoolVector2Array([cell2pixel(cell),cell2pixel(cell)+srd,cell2pixel(cell)+srd+sld,cell2pixel(cell)+sld])VisualServer.canvas_item_add_polygon(surf, points, [color])

Алгоритмы на сетках

Рисовать сетки конечно здорово, но на этом далеко не уедешь. В игре должны быть какие то механики, какое то движение и интерактивность, без этого это просто приложение, рисующее красивые (или не очень) каринки.

Поворот изометрического объекта

В играх часто нужно направить объект на курсор. В случае top-down вида это делается поворотом самого спрайта объекта, а вот в псевдо 3d вращать спрайт не очень хорошая затея:

Для создания объема необходимо несколько спрайтов, по одному на каждое направление. Для выбора нужного спрайта необходимо понять, какое из 8-ми направлений наиболее близко к направлению до курсора. Каждое из них можно задать относительную ячейку, ({-1; 0} - влево, {-1, -1} - влево вверх и т.д.) и искать направление до клетки через направляющие. Звучит немного непонятно, поэтому поясню. Чтобы перейти из ячейки {1, 2} в ячейку {5, 2} нам необходимо передвинутся вправо 4 раза. Понять мы это можем вычитаением из конечной ячейки начальной (5-1 = 4). Сколько именно раз надо передвинутся нам не важно, нас интересует направление. В данном случае разница между координатами положительна, значит двигаемя вдоль оси X. Тоже самое и с вертикальными координатами. Однако если мы просто запишем знаки разностей в координаты направляющего вектора, получится неравномерное распределение, ведь достаточно разности в единицу, чтобы вектор указывал на это направление. Тут лучше показать:

Как мы видим, кораблик смотрит вдоль осей только тогда, когда мы указываем прямо на них, а в остальных случаях он смотрит в какой то из углов. Так дело не пойдет, надо как то расширять углы осевых направлений. Для начала мы можем ограничить угловые направления посредством введения условия, что разность между разностями координат (WAT) будет менее какого то числа. Чем это число больше, тем больше область у угловых направлений. Если же эта разность больше этого числа, значит это одно из осевых направлений. Какое именно можно узнать, сравнив модули разностей кординат. Если разность больше по X, значит это горизонтальное направление, иначе вертикальное. Возможно звучит непонятно, но в коде должно быть яснее:

func direct_cell(cell1, cell2):var res = cell2-cell1 # вектор разности  # В качестве этой константы я выбрал 4, она показалась мне наиболее   # правдоподобнойif abs(abs(res.x)-abs(res.y)) > 4: # Проверяем, осевое ли это направлениеif abs(res.x) > abs(res.y): # Если да, то смотрим какое именноreturn Vector2(sign(res.x), 0) # Разница по x больше, значит горизонтальноеelse:return Vector2(0, sign(res.y)) # Разница по y боьше, значит вертикальноеelse:return Vector2(sign(res.x), sign(res.y)) # Угловове направление

Для удобной работы со спрайтами их можно разместить кругом:

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

Поиск пути

Пожалуй это все еще самый животрепещущий вопрос, хотя на эту тему написано огромное количество статей разнообразной сложности. Долго тут я распинаться не буду, потому что за меня уже все сказали. Для погружения в эту тему порекомендую данную статью на английском со всякими интерактивными демками или ее перевод на хабре с простыми картинками. От себя я лишь оставлю реализацию A* на godot:

Реализация
class PriorityStack:var items:Arrayfunc _init():items = Array()func empty() -> bool:return items.size() == 0func put(item, priority:int) -> void:if empty():items.append([item, priority])elif priority <= items[0][1]:items.insert(0, [item, priority])elif priority > items[-1][1]:items.append([item, priority])else:for i in range(len(items)):if priority <= items[i][1]:items.insert(i, [item, priority])breakfunc take(): # "get" name already taken by Variantreturn items.pop_front()[0]func in_map(grid_pos:Vector2, map_size:Vector2) -> bool:return grid_pos.x < map_size.x and grid_pos.x >= 0 and grid_pos.y >= 0 and grid_pos.y < map_size.yfunc can_stand(grid_pos:Vector2, obsts:PoolVector2Array, map_size:Vector2) -> bool:return not (grid_pos in obsts) and in_map(grid_pos, map_size)func neighbors(grid_pos:Vector2,  obsts:PoolVector2Array, map_size:Vector2) -> PoolVector2Array:var res:PoolVector2Array = []var _neighbors = PoolVector2Array([grid_pos+Vector2(-1, 0), grid_pos+Vector2(1, 0), grid_pos+Vector2(0, -1), grid_pos+Vector2(0, 1)])for neigh in _neighbors:if can_stand(neigh, obsts, map_size):res.append(neigh)return resfunc heuristic(a:Vector2, b:Vector2) -> int:return int(abs(a.x-b.x)+abs(a.y-b.y))func find_path(start:Vector2, goal:Vector2, obsts:PoolVector2Array, map_size:Vector2) -> PoolVector2Array:var frontier = PriorityStack.new()frontier.put(start, 0)var came_from = {}var cost_so_far = {}came_from[start] = startcost_so_far[start] = 0var current:Vector2var new_cost:intif not can_stand(goal, obsts, map_size):return PoolVector2Array()while not frontier.empty():current = frontier.take()if current == goal:breakfor next in neighbors(current, obsts, map_size):new_cost = cost_so_far[current] + 1if not (next in cost_so_far) or new_cost < cost_so_far[next]:cost_so_far[next] = new_costfrontier.put(next, new_cost + heuristic(goal, next))came_from[next] = currentif frontier.empty() and current != goal:return PoolVector2Array()current = goalvar path:PoolVector2Array = PoolVector2Array([current])while current != start:current = came_from[current]path.append(current)path.invert()path.remove(0) # removes first positionreturn path

Это версия алгоритма без различных модификаций для получения более красивых путей или для каких то определенных карт. Данный код не претендует на минимальный расход памяти и максимальную скорость, но он работает и работает надежно, я переношу его с платформы на платформу уже года 2 :) А вот доказательство:

Растеризация различных фигур

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

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

Алгоритм брезенхема

Тут реализована максимально простая версия алгоритма, опять же не претендующая на максимум производительности:

func rast_line(start:Vector2, goal:Vector2) -> PoolVector2Array:var res:PoolVector2Array = []var steep = abs(goal.y-start.y) > abs(goal.x-start.x)if steep:start = Vector2(start.y, start.x)goal = Vector2(goal.y, goal.x)var reverse = start.x > goal.xif reverse:var x = start.xstart.x = goal.xgoal.x = xvar y = start.ystart.y = goal.ygoal.y = yvar dx = goal.x - start.xvar dy = abs(goal.y - start.y)var error = dx/2var ystep = 1 if start.y < goal.y else -1var y = start.yfor x in range(start.x, goal.x+1):if steep:res.append(Vector2(y, x))else:res.append(Vector2(x, y))error -= dyif error < 0:y += ysteperror += dxif reverse:res.invert()return res

И вот так это все работает:

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

Окружность
func rast_circle(center:Vector2, radius:int) -> PoolVector2Array:var x:int = 0var y:int = radiusvar delta:int = 1-2*radiusvar error:int = 0var res:PoolVector2Array = []while y >= 0:if not center+Vector2(x, -y) in res:res.append(center+Vector2(x, -y))if not center+Vector2(-x, y) in res:res.append(center+Vector2(-x, y))if not center+Vector2(-x, -y) in res:res.append(center+Vector2(-x, -y))if not center+Vector2(x, y) in res:res.append(center+Vector2(x, y))error = 2*(delta+y)-1if delta < 0 and error <= 0:x += 1delta += 2*x+1elif delta > 0 and error > 0:y -= 1delta -= 2*y+1else:x += 1y -= 1delta += 2*(x-y)return res

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

Также оставлю здесь растеризацию эллипса. Если говорить честно, я уже не помню, что это за алгоритм и где я его взял, однако он очень похож на очередную модификация алгоритма Брезенхема. Его реализация:

Эллипс
func rast_ellipse(center:Vector2, size:Vector2):var res = PoolVector2Array([])var x = 0var y = size.yvar a_sqr = size.x*size.xvar b_sqr = size.y*size.yvar delta = 4*b_sqr*(x+1)*(x+1) + a_sqr*(2*y-1)*(2*y-1) - 4*a_sqr*b_sqrwhile (a_sqr*(2*y-1) > 2*b_sqr*(x+1)):res.append(center+Vector2(x, y))res.append(center+Vector2(-x, y))res.append(center+Vector2(x, -y))res.append(center+Vector2(-x, -y))if delta < 0:x += 1delta += 4*b_sqr*(2*x+3)else:x += 1delta = delta-8*a_sqr*(y-1)+4*b_sqr*(2*x+3)y -= 1delta = b_sqr*(2*x+1)*(2*x+1)+4*a_sqr*(y+1)*(y+1)-4*a_sqr*b_sqrwhile y+1 != 0:res.append(center+Vector2(x, y))res.append(center+Vector2(-x, y))res.append(center+Vector2(x, -y))res.append(center+Vector2(-x, -y))if delta < 0:y -= 1delta += 4*a_sqr*(2*y+3)else:y -= 1delta -= 8*b_sqr*(x+1) + 4*a_sqr*(2*y+3)x += 1return res

Выглядит это как то так:

Заключение

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

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

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

Я благодарен вам за прочтение и желаю такой удачи, какой не желал еще никто!

Подробнее..

Гексагональные тайлоыве миры

23.05.2021 20:21:44 | Автор: admin

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

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

Если вы не читали мою предыдущую статью про прямоугольные сетки, то рекомендую ознакомится, потому как я иногда буду опускать некоторые вещи, ссылаясь именно на нее. Дабы не изобретать велосипед, некоторые алгоритмы я взял с этой статьи на английском. Кто не хочет или не может в английский, есть ее перевод на хабре. Все демки и примеры сделаны на движке Godot Engine v 3.2.3 с использованием его встроенного языка.

Думаю в целом его синтаксис ясен, однако оставлю ссылки на некоторые функции:

Система координат

На протяжении всей статьи мы будем работать только с правильными шестиугольниками, у них все стороны равны. Работа с неправильными шестиугольниками лишена смысла в принципе. Если не брать всякие повороты и искажения, существует два вида шестиугольных сеток, вертикально и горизонтально ориентированных:

  • Такие я буду называть вертикальными (у ячейки есть явный вертикальный сосед):

  • А такие горизонтальными (у ячейки есть явный горизонтальный сосед):

Уже на этапе введения координат могут возникнуть проблемы. Дело в том, что в шестиугольной сетке невозможно ввести типичную декартову систему координат - всегда будет ось, вдоль которой у ячейки не будет явного соседа. На самом деле существует огромное количество систем координат для таких сеток. Более подробно о них рассказано в упомянутых вначале статьях. Я рассказывать про каждую не буду, все таки пост больше про использование сеток, а не их исследование.

Пожалуй первое, что приходит в голову, это таки впихнуть декартовы оси, однако для этого по одному из направлений координаты придется смещать:

Такая система называется координатами смещения. Бывает два вида координат смещения - четные и нечетные. Они отличаются лишь выбором соседа для смещения. В моем примере использованы нечетные координаты смещения, т.е. ячейка смещается к нижней в случае вертикальной ориентации или к правой в случае горизонтальной.

Главная проблема этих координат состоит в выборе базисов. Для разных ячеек базис к следующей ячейке может отличаться от того, по которому мы к ней перешли. Если вы помните из статьи про прямоугольные тайлы, там мы обращали матрицу из базисных векторов и умножали на нее радиус-вектор пикселя, получая координаты ячейки. Здесь так не выйдет, провернув тоже самое мы просто получим квадратную сетку.

Вообще у сетки шестиугольников есть три ярко выраженных оси:

Что то напоминает, не находите? Тут три оси, прям как в пространстве. На самом деле гений тот человек - кто не просто увидел, что если взглянуть на куб под определенным углом, то получится шестиугольник, а еще и додумался использовать трехмерные координаты в двумерной сетке шестиугольников. Правда вот попробовав посчитать координаты на двумерной сетке, могут вскипеть мозги, ведь третья ось тут кажется лишней и ее использование будто только мешает. Для разрешения данной ситуации просто посмотрим откуда взялись кубы:

Для получения сетки шестиугольников надо взглянуть в изометрии на пирамидку, на каждом уменьшении высоты которой увеличивается количество кубиков в ряду на один. Включив фантазию, можно увидеть в каждом кубе шестиугольник. Включив внимательность, можно увидеть в левом нижнем углу координаты. Включив логику, можно увидеть, что сумма этих самых координат всегда равна некоторому числу, которое, кстати говоря, может быть любым, ведь мы сами решаем, откуда начнется система координат. В моем случае это 15, просто потому, что система отсчета в этом редакторе начинается с нижнего дальнего угла (в случае изометрического вида). Нам незачем таскать за собой лишнюю константу, поэтому возьмем ее за ноль. Таким образом, сумма трех координат всегда и везде равна нулю, поэтому третья нам попросту не нужна, ведь мы можем получить ее из двух других. Теперь и получается, что "мешающая" третья ось уходит. Наконец то мы можем спокойно направить базисы и у каждой ячейки всегда будет точный сосед, находящийся вдоль оси:

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

Преобразование координат

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

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

Оранжевые (маленькие) диагонали делятся пополам зелеными (большими), а зеленые оранжевыми, если последние провести из середины стороны. Уже получается, что мы можем разделить шестиугольник на 4 прямоугольника. Однако некоторые вершины в таком случае будут лежать где то между углами сетки, а ведь нам хотелось бы, чтобы все они попадали ровно в углы. На самом деле "где то", это ровно по серединке, поэтому разделим большие ячейки еще на пополам, тогда все вершины шестиугольника будут ложиться точно в углы прямоугольной сетки:

Желтую сетку в дальнейшем я буду называть вспомогательной. Для задания ее базисов будем использовать такие значения:

# Для горизонтальных шестиугольниковvar hex_size = 32var short = int(size*sqrt(3)/2) # 1/2 from short hex diagonalvar long = int(size/2) # 1/4 from long hex diagonal

Теперь мы можем выразить базисы шестиугольной сетки, используя базисы вспомогательной сетки:

Запишем все базисы в коде:

...# Transorm2D в godot - это матрица 3x2, где последняя строка указыает# смещение объекта, в дальнейшем она не будет использоваться совсем, # поэтому считайте это просто матрицей 2x2. Сделано это для удобства,# на объяснения никак не повлияет.# У нее есть два атрибута - x и y. Каждый из них это вектор. X - представляет# первый столбец матрицы 2x2 (крайняя строка не учитывается), Y - второй столбец.  var grid_basis = Transform2D() # Матрица базисов вспомогательной сеткиvar hex_basis = Transform2D() # Матрица базисов гексагональной сетки...  # Для вертикальной сеткиgrid_basis.x = Vector2(long, 0)grid_basis.y = Vector2(0, short)hex_basis.x = grid_basis.x*3 + grid_basis.yhex_basis.y = grid_basis.y*2# Для горизонтальной сеткиgrid_basis.x = Vector2(short, 0)grid_basis.y = Vector2(0, long)hex_basis.x = grid_basis.x*2hex_basis.y = grid_basis.x+grid_basis.y*3

Я пользуюсь именно встроенными средствами Godot для упрощения работы в целом. Все подобные места будут поясняться в общем виде, как бы это делалось без встроенных возможностей.

Шестиугольник в пиксель

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

func hex2pixel(hex):return hex.x*hex_basis.x + hex.y*hex_basis.y

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

Тогда для получения вершины в коде прибавляем нужный вектор (см. картинку выше) к центру шестиугольника. Я написал только функцию получения массива вершин, ибо по отдельности они почти никогда не нужны.

Для вертикальных шестиугольников:

func _get_vert_hex_vertices(hex):var pixel = hex2pixel(hex)return PoolVector2Array([pixel+2*grid_basis.x,pixel+grid_basis.x+grid_basis.y,pixel-grid_basis.x+grid_basis.y,pixel-2*grid_basis.x,pixel-grid_basis.x-grid_basis.y,pixel+grid_basis.x-grid_basis.y])

Для горизонтальных шестиугольников:

func _get_hor_hex_vertices(hex):var pixel = hex2pixel(hex)return PoolVector2Array([pixel+grid_basis.x-grid_basis.y,pixel+grid_basis.x+grid_basis.y,pixel+2*grid_basis.y,pixel-grid_basis.x+grid_basis.y,pixel-grid_basis.x-grid_basis.y,pixel-2*grid_basis.y,])

Пиксель в шестиугольник

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

Для горизонтальной ориентации

В коде это записывается так:

func pixel2hex(pixel):var x = pixel.x/(2*cw) - pixel.y/(6*ch)var y = pixel.y/(3*ch)return round_hex(Vector2(x, y))
Для вертикальной ориентации

В коде это записывается так:

func pixel2hex(pixel):var x = pixel.x/(3*cw)var y = pixel.y/(2*ch) - pixel.x/(6*cw)return round_hex(Vector2(x, y))

Однако я буду пользоваться функцией affine_inverse у Transform2D, для того, что бы при изменении базисных векторов постоянно не менять функции преобразований, позже увидите зачем это надо. Вы скорее всего работаете в другой среде (и зря), поэтому вам придется писать обращение матрицы самостоятельно. Кто не знает как это делается, или забыл, может почитать тут, или переписать следующие функции в свой язык:

Функции
func invert_basis(basis:Transform2D): # обращение матрицыvar det = basis.x.x*basis.y.y - basis.y.x*basis.x.yvar idet = 1.0/det# Я не уверен что Transform2D передается по значению, по этому# копирую данные в новый объектvar res = basisres.y.y = basis.x.x*idetres.x.x = basis.y.y*idetres.x.y = -basis.x.y*idetres.y.x = -basis.y.x*idetreturn resfunc vec_mul_basis(vec:Vector2, basis:Transform2D): # умножение вектора на матрицуvar x = vec.x*basis.x.x + vec.y*basis.y.xvar y = vec.x*basis.x.y + vec.y*basis.y.yreturn Vector2(x, y)func pixel2hex(pixel):return round_hex(vec_mul_basis(pixel, invert_basis(hex_basis)))

Средствами Godot это можно записать всего в одну строчку:

func pixel2hex(pixel):return round_hex(hex_basis.affine_inverse().xform(pixel))

Тут .xform(Vector2) - это метод для умножения матрицы на переданный в него вектор, аналог vec_mul_basis из моего кода. Такой код работает для обеих ориентаций.

Если вы хотя бы бегло прочитали вышеприведенный код, то наверняка заметили функцию round_hex вместо типичных приведений к int. Дело в том, что полных координат у шестиугольника 3, и они обладают условием x + y + z = 0, а после округления каждой из них равенство может нарушиться. Поэтому необходимо задать координату с наибольшей ошибкой округления через две другие, тогда условие выполнится. Да, данный метод полностью слизан отсюда, однако зачем придумывать велосипед, если можно взять готовый? Так же тут используется именно round, а не приведение к int, ведь основание каждой ячейки находится в ее центре, а не в левом верхнем углу, как в случае с прямоугольными сетками:

func round_hex(hex:Vector2):var rx = round(hex.x)var ry = round(hex.y)var rz = round(-hex.x-hex.y) # z = -x-yvar x_diff = abs(hex.x-rx) # Ошибка округления xvar y_diff = abs(hex.y-ry) # Ошибка округления yvar z_diff = abs(-hex.x-hex.y-rz) # Ошибка округления zif x_diff > y_diff and x_diff > z_diff:rx = -ry-rz # Приведение под равенствоelif y_diff > z_diff:ry = -rx-rz # Приведение под равенствоreturn Vector2(rx, ry)

Работает все замечательно:

Вертикальная ориентация
Горизонтальная ориентация

Однако я надеюсь вы не думаете, что сетки, это вручную нарисованные текстуры. Я не самоубийца.

Рисование сеток

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

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

const hex_map_size = Vector2(7, 7) # размер сетки шестиугольниковvar grid_map_size:Vector2 # размер вспомогательной сетки...grid_map_size.x = hex_map_size.x*2grid_map_size.y = hex_map_size.y*3+1

Для вертикальных шестиугольников все аналогично, только формулы для вычисления ширины и высоты меняются местами:

...grid_map_size.x = hex_map_size.x*3+1grid_map_size.y = hex_map_size.y*2

Сетку из шестиугольников можно разбить на две части, на вертикальные линии и на паттерн вершин:

Будем рисовать каждую составляющую по отдельности. Начнем с вертикальных линий. Можно заметить, что в каждом ряду линии рисуются с интервалом в 2 ячейки, а каждый четный по счету ряд начинается со второй, а не с первой ячейки. Также увидим то, что первый ряд начинается со со смещением в одну ячейку относительно верхей границы, а ряды разделяет одна ячейка. С учетом того, что длина штриха в две ячейки, между верхними концами отрезков находятся три ячейки. Тогда в цикле начинаем с единицы и идем до нижнего края карты с шагом 3, а во втором цикле начинаем со столбца, индекс которого обратен четности ряда, проще говоря 1-i%2, и идем до правого края карты, но на единицу больше, чтобы нарисовать таки крайние линии, с шагом в две ячейки. В кадой итерации второго цикла просто рисуем отрезок высотой две ячейки:

for i in range(1, grid_map_size.y, 3):for j in range(1-i%2, grid_map_size.x+1, 2):VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i, grid_basis.x*j+grid_basis.y*(i+2), color, width, antialiasing)

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

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

Для рисования паттернов пробегаем каждую третью строку, начиная с нулевой, а в каждой строке пробегаемся по столбцам. Тогда для выбора нужной линии сравниваем четности строки и столбца, если они совпадают, то рисуем нижнюю диагональ, иначе верхнюю. Тут я считаю нужным показать, как задается каждый угол ячейки с координатами {j, i} , где j - столбец (как бы x), i - строка (как бы y). Размер ячейки увеличен только для демонстрации:

В коде этот алгоритм выглядит так:

# Drawing verticesfor i in range(0, grid_map_size.y, 3): # рисуем на каждой третьей строкеfor j in range(grid_map_size.x): # крайний столбец не захватываем, т.к. в коде прибавляется единицаif i%2 == j%2: # нижняя диагональCanvas.line(surf, grid_basis.x*j+grid_basis.y*(i+1), grid_basis.x*(j+1)+grid_basis.y*i, color, width, antialiasing)else: # верхняя диагональCanvas.line(surf, grid_basis.x*j+grid_basis.y*i-offset, grid_basis.x*(j+1)+grid_basis.y*(i+1), color, width, antialiasing)

Однако просто нарисовав на холсте сетку, получатся непонятки с координатами:

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

Однако и на этом не все. Если просто объеденить весь код выше в одну функцию, то при четных высотах она будет рисовать ненужные хвосты:

Эти хвосты рисуются прямо в углах вспомогательной сетки, поэтому просто добавим уловие, что мы в них не находимся, иначе просто не рисуем тут диагональ.

Соеденив все вместе, получим такую функцию:

func _draw_hor_rect_grid(surf:RID, color:Color, width=1.0, antialiasing=false):var offset = grid_basis.x+grid_basis.y*2# Drawing vertical linesfor i in range(1, grid_map_size.y, 3):for j in range(1-i%2, grid_map_size.x+1, 2):VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i-offset, grid_basis.x*j+grid_basis.y*(i+2)-offset, color, width, antialiasing)# Drawing verticesfor i in range(0, grid_map_size.y, 3):for j in range(grid_map_size.x):if int(hex_map_size.y)%2 == 1 or not (i == grid_map_size.y-1 and (j == 0 or j == grid_map_size.x-1)):if i%2 == j%2:VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(i+1)-offset, grid_basis.x*(j+1)+grid_basis.y*i-offset, color, width, antialiasing)else:VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i-offset, grid_basis.x*(j+1)+grid_basis.y*(i+1)-offset, color, width, antialiasing)

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

func draw_auxiliary_grid(surf:RID, color:Color, width=1.0, antialiasing=false):var offset = grid_basis.x+grid_basis.y*2for i in grid_map_size.x+1:Canvas.line(surf, grid_basis.x*i-offset, grid_basis.x*i+grid_basis.y*grid_map_size.y-offset, color, width, antialiasing)for i in grid_map_size.y+1:Canvas.line(surf, grid_basis.y*i-offset, grid_basis.x*grid_map_size.x+grid_basis.y*i-offset, color, width, antialiasing)

И, как и обещал, функция для рисования вертикально-ориентированной сетки:

func _draw_vert_rect_grid(surf:RID, color:Color, width=1.0, antialiasing=false):var offset = grid_basis.x*2+grid_basis.y# Drawing horizontal linesfor i in range(1, grid_map_size.x, 3):for j in range(1-i%2, grid_map_size.y+1, 2):VisualServer.canvas_item_add_line(surf, grid_basis.x*i+grid_basis.y*j-offset, grid_basis.x*(i+2)+grid_basis.y*j-offset, color, width, antialiasing)# Drawing verticesfor i in range(0, grid_map_size.x, 3):for j in range(grid_map_size.y):if int(hex_map_size.x)%2 == 1 or not(i == grid_map_size.x-1 and (j == 0 or j == grid_map_size.y-1)):if j%2 == i%2:VisualServer.canvas_item_add_line(surf, grid_basis.x*(i+1)+grid_basis.y*j-offset, grid_basis.x*i+grid_basis.y*(j+1)-offset, color, width, antialiasing)else:VisualServer.canvas_item_add_line(surf, grid_basis.x*i+grid_basis.y*j-offset, grid_basis.x*(i+1)+grid_basis.y*(j+1)-offset, color, width, antialiasing)

Результат вполне неплох, нигде линии не рисуются дважды (сетка рисовалась немного прозрачной на черном фоне, а яркость линий везде оинакова):

Сетка вертикальных шестиугольников
Сетка горизонтальных шестиугольников

Однако рендерить такие сетки в реальном времени довольно затратно, тут рисуется множетсво отдельных отрезков, что сильно замедляет работу. Просто для примера, пустое черно окно у меня имеет fps около 950, а при рисовании белым цветом Color8(255, 255, 255, 200) шестиугольной сетки размера 10x10 и размером шестиугольнкиа 32 пикселя, fps примерно 260. Так что рисовать сетки процедурно резонно только на начальном этапе разработки, потом лучше отрендерить ее заранее и использовать как текстуру.

Рисование шестиугольной сетки шестиугольников

Сетки шестиугольников конечно здорово, но иногда хочется чего то большего. Например больше шестиугольников, поэтому сделаем большой шестиугольник из маленьких. Такая сетка может пригодиться, если мы, например, делаем шестиугольные шахматы.

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

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

var hex_map_size = Vector2(5, <не имеет значения>)...var diagonal = hex_map_size.x*2-1

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

...grid_map_size.x = diagonal*2grid_map_size.y = diagonal*3+1

Для вертикальных значения меняются местами:

grid_map_size.x = diagonal*3+1grid_map_size.y = diagonal*2

Шестиугольную сетку можно точно также разбить на две части, на паттерн вершин и вертикальные линии:

Начнем с рисования вершин. Рисовать каждый слой по-отдельности не имеет сымсла, ведь фигура симметрична. Мы можем разделить всю вспомогательную сетку на четыре части и, нарисовав одну четверть, отобразить ее зеркально на все остальные. Сетка кстати всегда будет делиться ровно, и вот почему. По горизонтали понятно, ведь в формуле ширины мы удваиваем диагональ шестиугольной карты. А эта самая диагональ будет всегда нечетна, ведь мы от четного числа отнимаем единицу (hex_map_size.x*2-1). В формуле высоты вспомогательной сетки мы умножаем эту диагональ на 3, и результат получится тоже нечетным, а после прибавления единицы все выражение становится четным. Таким образом ширина и высота вспомогательной сетки всегда четны, и как следствие, ее можно всегда разделить на четыре одинаковые части:

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

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

Также вспомним, что каждый следующий паттерн рисуется со смещением в три ячейки от предыдущего, поэтому в цикле идем от нуля до половины высоты вспомогательной сетки с шагом в три, попутно вычисляя смещение для каждого ряда:

for i in range(0, grid_map_size.y/2, 3): # Drawing vertices  # тут i/3 потому что мы идем со смещением 3, а при расчетах нужен индекс  start = hex_map_size.x-1 - i/3  

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

for i in range(0, grid_map_size.y/2, 3): # Drawing vertices  # тут i/3 потому что мы идем со смещением 3, у при расчетах нужен индекс паттерна  start = hex_map_size.x-1 - i/3    for j in range(start, grid_map_size.x/2):  pass # Пока ничего не делаем

Каждый паттерн при рисовании шестиугольной карты четный, а вот смещение чередует свою четность. Четность начального смещения напрямую зависит от четности размера карты - они противоположны. Двигаясь вниз по рядам паттерна, индекс ряда меняет четность, как и смещение для этого ряда. Если помните, для выбора диагонали при рисовании прямоугольной сетки мы сравнивали четность ряда и колонки. Тут же меняются обе четности, и при разных раземрах карты они будут то совпадать при начальных значениях, то нет.

Приведу пример. Мы рисуем нижнюю диагональ, если индексы ряда и колонки совпадают, иначе верхнюю. Поставим размер карты 5. Тогда начальное смещение будет четным, как и индекс первого ряда (i=0). Исходя из условия, рисуем нижнюю диагональ, как и должно быть. Однако поставив четный размер, скажем, 4, начальное смещение будет нечетным, а вот индекс первого ряда по прежнему четным. Тогда взглянув на условие компьютер выберет верхюю диагональ, а ведь нам все еще для начала нужна нижняя. Вот как это будет выглядеть:

Тут на самом деле всего лишь надо поменять четность паттерна, тогда все встанет на свои места. Получается, выбор условия рисвания нижней диагонали зависит от четности самого размера карты. Тут можно заметить, что разница четностей столбца и ряда в каждой первой диагонали ряда паттерна обратна четности размера карты. А при рисовании паттерна диагонали просто чередуются, как и чередуется четность столбца, и как следствие чередуется равенство разностей четностей ряда и столбца и четности размера карты. Поэтому для выбора диагонали используем равентво abs(i%2 - j%2) != parity, где parity - это остаток от деления размера карты на два. Если это условие верно, рисуем нижнюю диагональ, иначе верхнюю. Получим то что нужно, осталось отразить по красным линиям:

Код рисования четверти всего паттерна
func _draw_hor_hex_grid(surf:RID, color:Color):var parity = int(hex_map_size.x)%2var startfor i in range(0, grid_map_size.y/2, 3): # Drawing verticesstart = hex_map_size.x - i/3 - 1for j in range(start, grid_map_size.x/2):if abs(i%2 - j%2) != parity:# Down diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(i+1), grid_basis.x*(j+1)+grid_basis.y*i, color)      else:# Top diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(i), grid_basis.x*(j+1)+grid_basis.y*(i+1), color)

Для отражения точек рисования отнимаем от края вспомогательной сетки индекс точки, ничего сложного. А вот в коде это выглядит громоздко. Взгляните сами:

func _draw_hor_hex_grid(surf:RID, color:Color, width=1.0, antialiasing=false):var parity = int(hex_map_size.x)%2var startfor i in range(0, grid_map_size.y/2, 3): # Drawing verticesstart = hex_map_size.x - i/3 - 1for j in range(start, grid_map_size.x/2):if abs(i%2 - j%2) != parity:# Down diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(i+1), grid_basis.x*(j+1)+grid_basis.y*i, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i+1), grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*i, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(grid_map_size.y-i-1), grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i), color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(grid_map_size.y-i-1), grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(grid_map_size.y-i), color)else:# Top diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(i), grid_basis.x*(j+1)+grid_basis.y*(i+1), color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i), grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(i+1), color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(grid_map_size.y-i), grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i-1), color)VisualServer.ca

Но ничего страшного, ковид пережили - переживем и это. Зато получаем правильный паттерн для сеток с четными и нечетным размером:

Если вы что то поняли во всей этой мешанине четностей, то либо вы гений, либо у меня получилось что то объяснить. Однако дальше не легче, но радует то, что мы почти нарисовали сетку. Осталось добавить вертикальные линии - это будет финальным боссом в рассказе о рисовании сеток.

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

for i in range(1, grid_map_size.y, 3):for j in range(1-i%2, grid_map_size.x+1, 2):VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i, grid_basis.x*j+grid_basis.y*(i+2), color, width, antialiasing)

Однако просто скопипастив его в нашу функцию, получим кривое рисование при четных размерах карты, ведь при них первый ряд должен иметь смещение в единицу, а при нечетных этого смещения быть не должно. Это вытекает из смещения первого шестиугольника в первом ряду, при четных значения оно нечетно поэтому и рисуем со смещением, и наоборот. Для выбора смещения сравним четности размера карты и ряда, если они отличаются, то рисуем без смещения, иначе со смещением. Пихать сюда условие не имеет смысла, ведь мы можем выбрать смещение через отличие четности карты и четности столбца конструкцией abs(parity-i%2). Просто напомню - parity это остаток от деления размера карты на два. Проверьте сами, при четных столбцах и нечетных размерах карты получается единица - то самое смещение. Запишем это выражение в смещение в цикле:

for i in range(1, grid_map_size.y, 3):for j in range(abs(parity-i%2), grid_map_size.x+1, 2):VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i, grid_basis.x*j+grid_basis.y*(i+2), color, width, antialiasing)

Цель почти достигнута, осталось избавиться от лишних линий по углам:

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

...start = hex_map_size.x-1 - i/3

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

...start = (i-grid_map_size.y/2)/3

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

for i in range(1, grid_map_size.y, 3):if i <= grid_map_size.y/2:start = hex_map_size.x-1 - i/3else:start = (i-grid_map_size.y/2)/3for j in range(abs(parity-i%2), grid_map_size.x+1, 2):if j >= start and j <= grid_map_size.x-start: # избавляемся от лишних линийVisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i, grid_basis.x*j+grid_basis.y*(i+2), color, width, antialiasing)

Вот и все - финальный босс побежден. Осталось только добавить смещение для расположения сетки в начало координат, offset = grid_basis.x+grid_basis.y*2. Однако тут опять играет роль четность размера карты, так что когда она четна прибавляем к смещению горизонтальный базис ячейки.

Босса то убили, а вот лут забыли. С него мы получили рисование шестиугольных сеток шестиугольников:

Горизонтальная ориентация
func _draw_hor_hex_grid(surf:RID, color:Color, width=1.0, antialiasing=false):var parity = int(hex_map_size.x)%2var offset = grid_basis.x+grid_basis.y*2 + grid_basis.x*(1-parity)var startfor i in range(0, grid_map_size.y/2, 3): # Drawing verticesstart = hex_map_size.x - i/3 - 1for j in range(start, grid_map_size.x/2):if abs(i%2 - j%2) != parity:# Down diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(i+1)-offset, grid_basis.x*(j+1)+grid_basis.y*i-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i+1)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*i-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(grid_map_size.y-i-1)-offset, grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(grid_map_size.y-i-1)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(grid_map_size.y-i)-offset, color)else:# Top diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(i)-offset, grid_basis.x*(j+1)+grid_basis.y*(i+1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(i+1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)for i in range(1, grid_map_size.y, 3):if i <= grid_map_size.y/2:start = hex_map_size.x-1 - i/3else:start = (i-grid_map_size.y/2)/3for j in range(abs(parity-i%2), grid_map_size.x+1, 2):if j >= start and j <= grid_map_size.x-start:VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i-offset, grid_basis.x*j+grid_basis.y*(i+2)-offset, color, width, antialiasing)
Вертикальная ориентация
func _draw_vert_hex_grid(surf:RID, color:Color, width=1.0, antialiasing=false):var parity = int(hex_map_size.x)%2var offset = grid_basis.x*2+grid_basis.y + (1-parity)*grid_basis.yvar startfor j in range(0, grid_map_size.x/2, 3): # Drawing verticesstart = hex_map_size.x - j/3 - 1for i in range(start, grid_map_size.y/2):if abs(i%2 - j%2) != parity:# Down diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*(j+1)+grid_basis.y*(i)-offset, grid_basis.x*(j)+grid_basis.y*(i+1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(i)-offset, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i+1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(j)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)else:# Top diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(i)-offset, grid_basis.x*(j+1)+grid_basis.y*(i+1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(i+1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)for i in range(1, grid_map_size.x, 3):if i <= grid_map_size.x/2:start = hex_map_size.x-1 - i/3else:start = (i-grid_map_size.x/2)/3for j in range(abs(parity-i%2), grid_map_size.y+1, 2):if j >= start and j <= grid_map_size.y-start:VisualServer.canvas_item_add_line(surf, grid_basis.x*i+grid_basis.y*j-offset, grid_basis.x*(i+2)+grid_basis.y*(j)-offset, color, width, antialiasing)

Пример:

Рисование шестиугольников

Тут на самом деле все просто, можете расслабиться. После этой жести с четностями ничего хуже уже не будет. Для рисования шестиугольников вспомним функции для получения вершин, которые я приводил выше и просто соеденим массив вершин шестиугольника линиями:

Функции для получения вершин, если лень мотать неаверх
func _get_vert_hex_vertices(hex):var pixel = hex2pixel(hex)return PoolVector2Array([pixel+2*grid_basis.x,pixel+grid_basis.x+grid_basis.y,pixel-grid_basis.x+grid_basis.y,pixel-2*grid_basis.x,pixel-grid_basis.x-grid_basis.y,pixel+grid_basis.x-grid_basis.y])func _get_hor_hex_vertices(hex):var pixel = hex2pixel(hex)return PoolVector2Array([pixel+grid_basis.x-grid_basis.y,pixel+grid_basis.x+grid_basis.y,pixel+2*grid_basis.y,pixel-grid_basis.x+grid_basis.y,pixel-grid_basis.x-grid_basis.y,pixel-2*grid_basis.y,])

И рисуем множество линий между точками, не забыв замкнуть цепь:

func _draw_hor_hex(hex, surf, color, width=1.0, antialiasing=false):var points = _get_hor_hex_vertices(hex)points.append(points[0]) # замыкаемVisualServer.canvas_item_add_polyline(surf, points, [color], width, antialiasing)func _draw_vert_hex(hex, surf, color, width=1.0, antialiasing=false):var points = _get_vert_hex_vertices(hex)points.append(points[0]) # замыкаемVisualServer.canvas_item_add_polyline(surf, points, [color], width, antialiasing)

Для заливки шестиугольника, по аналогии с прямоугольником, рисуем полигон:

func _fill_hor_hex(hex, surf, color, antialiasing=false):var points = _get_hor_hex_vertices(hex)VisualServer.canvas_item_add_polygon(surf, points, [color], [], RID(), RID(), antialiasing)func _fill_vert_hex(hex, surf, color, antialiasing=false):var points = _get_vert_hex_vertices(hex)VisualServer.canvas_item_add_polygon(surf, points, [color], [], RID(), RID(), antialiasing)

Выгялдит все это как то так:

Шестиугольные сетки в изометрии

Что может быть лучше сетки шестиугольников? Правильно, сетка шестиугольников в изометрии. Вы могли заметить, что ни в одной функции я не использовал числа - везде я работал с векторами. А это значит, что поменяв базис, автоматически поменяются функции - так и должно работать программирование. Помните, в части про преобразование координат я все вычисления автоматизировал? Так вот, наконец то нам это пригодится.

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

...const iso_scale = 2.0

Тогда для изменения вида делим y-координату каждого базиса вспомогательной сетки на это искажение:

# Вертикальная ориентацияgrid_basis.x = Vector2(long, 0)grid_basis.y = Vector2(0, short/iso_scale)# Горизонтальная ориентацияgrid_basis.x = Vector2(short, 0)grid_basis.y = Vector2(0, long/iso_scale)

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

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

# для вертикальныхvar pw = int(long*cos(PI/4))var ph = int(short*cos(PI/4))grid_basis.x = Vector2(pw, pw/iso_scale)grid_basis.y = Vector2(-ph, ph/iso_scale)# для горизонтальныхvar pw = int(short*cos(PI/4))var ph = int(long*cos(PI/4))grid_basis.x = Vector2(pw, pw/iso_scale)grid_basis.y = Vector2(-ph, ph/iso_scale)

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

Красиво, конечно, но игру на этом не сделать. Нужно также уметь что то на этих сетках делать.

Изометрические преобразования

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

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

Посмотрев на нее, все сразу становится ясно. В случае горизонтальной ориентации при каждом перемещении по x на шестигольной сетке мы двигаемся на две ячейки вспомогательной, а при движении по y мы движемся на одну ячейку вправо и на три вниз. Для вертикальных применима эта же логика. По этим формулам написать функции можно проще простого:

# Для вертикальныхfunc get_center_cell(hex:Vector2):return Vector2(hex.x*3, hex.y*2+hex.x)# для горизонтальныхfunc get_center_cell(hex:Vector2):return Vector2(hex.x*2+hex.y, hex.y*3)

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

Расстояние на сетке

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

func hex_distance(hex1:Vector2, hex2:Vector2):var dif = (hex2-hex1)return (abs(dif.x) + abs(dif.y) + abs(-dif.x-dif.y))/2 # z = -x-y

Сеточное направление

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

Для нахождения направляющей разделим сетку по трем осям и заметим, что в каждой части получившейся сетки одна из трех координат максимальна по модулю:

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

func direct_hex(hex1:Vector2, hex2:Vector2):var dx = hex2.x - hex1.xvar dy = hex2.y - hex1.yvar dz = -hex2.x-hex2.y + hex1.x+hex1.yif dx == 0: # Ось yreturn Vector2(0, sign(dy)) # Возвращаем ось yelif dy == 0: # Ось xreturn Vector2(sign(dx), 0) # Возвращаем ось xelif dz == 0: # Ось zreturn Vector2(sign(dx), sign(dy)) # Возвращаем ось zelse:if abs(dz) > abs(dx) and abs(dz) > abs(dy): # модуль разности по z оказался наибольшимif abs(dx) > abs(dy): # т.к. разность по x больше, значит мы отошли по x дальше, чем по y, значит выдаем ось xreturn Vector2(sign(dx), 0) # возвращаем ось xelse: # т.к. разность по y больше, значит мы отошли по y дальше, чем по x, значит выдаем ось yreturn Vector2(0, sign(dy)) # возвращаем ось y        elif abs(dy) > abs(dx): # модуль разности по y оказался наибольшимif abs(dz) > abs(dx): # по аналогииreturn Vector2(0, sign(dy)) # возвращаем y. Это связанно с представлением z-координаты через две другиеelse: # по аналогииreturn Vector2(sign(dx), sign(dy)) # возвращаем z        else: # модуль разности по x оказался наибольшимif abs(dy) > abs(dz): # по аналогииreturn Vector2(sign(dx), sign(dy)) # возвращаем zelse: # по аналогииreturn Vector2(sign(dx), 0) # возвращаем x

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

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

Поиск пути

Основной алгоритм поиска пути тот же, что и у обычной прямоугольной сетки, отличаются только соседи и проверки на нахождение точки внутри карты. Главный алгоритм - A*, его не трогаем совсем.

Соседи у шестиугольника выглядят как то так:

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

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

Начнем с прямоугольной карты. Для наглядности напомню как она выглядит:

Синим обозначены границы карты. Оси в такой сетке идут не параллельно сторонам прямоугольника, поэтому просто ограничить их нулем и границей карты не выйдет. Так сработает только для Y оси сетки, а горизонтальные границы зависят от смещения по Y. Перемещаясь вдоль оси Y, расстояние до левой границы в ячейках вспомогательной сетки увеличивается на единицу, значит на половину шестиугольника. Аналогично с правой границей, тоолько до нее расттояние уменьшается. При округлении левой границы используем floor, т.к. когда граница проходит ровно между шестиугольниками, мы вмыбираем тот, что внутри. По аналогии используем ceil для правой границы:

func _in_rect_grid_hor(hex):return hex.x >= -floor(hex.y/2) and hex.x < hex_map_size.x-ceil(hex.y/2) and hex.y < hex_map_size.y and hex.y >= 0

Для вертикальной ориентации логика точно такая же. Вот функция для нее:

func _in_rect_grid_vert(hex):return hex.x >= 0 and hex.x < hex_map_size.x and hex.y >= -floor(hex.x/2) and hex.y < hex_map_size.y-ceil(hex.x/2)

Теперь про шестиугольную карту. Ее вид:

Для простоты вычислений будем считать границы от центра карты. Просто потыкав и посмотрев на координаты я пришел к следующим формулам центров:

# для горизонтальныхfunc _get_hor_hex_map_center():return Vector2(int((hex_map_size.x-1)/2), hex_map_size.x-1)# для вертикальныхfunc _get_vert_hex_map_center():return Vector2(hex_map_size.x-1, int((hex_map_size.x-1)/2))

Каждому смещению по Y соответствует уменьшение длины ряда на единицу, так и будем задавать границы по x. В качестве размеров, ограничивающих карту, возьмем диагональ. Как ее вычислять я рассказывал ранее. Тогда по Y границами будут просто половины этих диагоналей, а по X одна из граней всегда параллельна оси Y, поэтому уменьшаться будет либо правая либо левая граница. Какая именно можно понять по вертикальной половине шестиугольника. Если мы находимся в верхней, то уменьшается левая граница (т.к. правая параллельна оси Y), если в нижней то уменьшается правая граница (т.к. левая паралельна оси Y). А если мы находимся прямо на горизонтальной диагонали то нам плевать, ведь на ней смещение по Y равно нулю.

Вот функции, реализующие данную логику для обеих ориентаций:

# для горизонтальныхfunc _in_hex_grid_hor(hex):    var center = _get_hor_hex_map_center()    var diag = int(hex_map_size.x*2 - 1)    hex -= center # Vector2 passed by value; getting hex regarding map center    if hex.y < 0:        return hex.x >= -diag/2+abs(hex.y) and hex.x <= diag/2 and hex.y >= -diag/2 and hex.y <= diag/2    else:        return hex.x >= -diag/2 and hex.x <= diag/2-abs(hex.y) and hex.y >= -diag/2 and hex.y <= diag/2# для вертикальныхfunc _in_hex_grid_vert(hex):    var center = _get_vert_hex_map_center()    var diag = int(hex_map_size.x*2 - 1)    hex -= center # Vector2 passed by value; getting hex regarding map center    if hex.x < 0:        return hex.y >= -diag/2+abs(hex.x) and hex.y <= diag/2 and hex.x >= -diag/2 and hex.x <= diag/2    else:        return hex.y >= -diag/2 and hex.y <= diag/2-abs(hex.x) and hex.x >= -diag/2 and hex.x <= diag/2

Для проверки поставим условие, что для рисования шестиугольника под курсором он должен быть внутри сетки:

Отлично, теперь можно спокойно реализовывать алгоритм поиска пути:

Ищем путь истинный
class PriorityStack:var items:Arrayfunc _init():items = Array()func empty() -> bool:return items.size() == 0func put(item, priority:int) -> void:if empty():items.append([item, priority])elif priority <= items[0][1]:items.insert(0, [item, priority])elif priority > items[-1][1]:items.append([item, priority])else:for i in range(len(items)):if priority <= items[i][1]:items.insert(i, [item, priority])breakfunc take():return items.pop_front()[0]func in_map(hex):match grid_type:GridTypes.hex:if hex_type == HexTypes.hor:return _in_hex_grid_hor(hex)else: # Verticalreturn _in_hex_grid_vert(hex)GridTypes.rect:if hex_type == HexTypes.vert:return _in_rect_grid_vert(hex)else: # Hor orientationreturn _in_rect_grid_hor(hex)func can_stand(hex:Vector2, obsts:PoolVector2Array):return in_map(hex) and not (hex in obsts)func neighbors(hex_pos:Vector2, obsts:PoolVector2Array):var res:PoolVector2Array = []var _neighbors = PoolVector2Array([Vector2(-1, 0), Vector2(1, -1), Vector2(0, -1), Vector2(1, 0), Vector2(0, 1), Vector2(-1, 1)])for i in _neighbors:if can_stand(i+hex_pos, obsts):res.append(i+hex_pos)return resfunc find_path(start:Vector2, goal:Vector2, obsts:PoolVector2Array):var frontier = PriorityStack.new()frontier.put(start, 0)var came_from = {}var cost_so_far = {}came_from[start] = startcost_so_far[start] = 0var current:Vector2var new_cost:intif not can_stand(goal, obsts):return PoolVector2Array()while not frontier.empty():current = frontier.take()if current == goal:breakfor next in neighbors(current, obsts):new_cost = cost_so_far[current] + 1if not (next in cost_so_far) or new_cost < cost_so_far[next]:cost_so_far[next] = new_costfrontier.put(next, new_cost+hex_distance(goal, next))came_from[next] = currentif frontier.empty() and current != goal:return PoolVector2Array()current = goalvar path = PoolVector2Array([current])while current != start:current = came_from[current]path.append(current)path.invert()path.remove(0) # removes first positionreturn pathfunc hex_distance(hex1:Vector2, hex2:Vector2):var dif = (hex2-hex1)return (abs(dif.x) + abs(dif.y) + abs(-dif.x-dif.y))/2

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

Растеризация отрезка

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

Растеризуем нерастеризуемое
func rast_line(hex1, hex2):var N = hex_distance(hex1, hex2)if N == 0: return PoolVector2Array([hex1])var res = PoolVector2Array()for i in range(N+1):res.append(round_hex(lerp(hex1, hex2, i/N)))return res

Вот так это выглядит:

Пару слов в завершение

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

Если я забыл про что рассказать или упустил какие то важные моменты, или же просто ошибаюсь, напишете об этом в комментарии.

Я надеюсь эта статья позволит вам полностью реализовать давние мечты по созданию "убийы героев" или что она позволила просто интересно провести вечер. До скорого!

Подробнее..

Гексагональные тайловые миры

23.05.2021 22:14:34 | Автор: admin

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

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

Если вы не читали мою предыдущую статью про прямоугольные сетки, то рекомендую ознакомится, потому как я иногда буду опускать некоторые вещи, ссылаясь именно на нее. Дабы не изобретать велосипед, некоторые алгоритмы я взял с этой статьи на английском. Кто не хочет или не может в английский, есть ее перевод на хабре. Все демки и примеры сделаны на движке Godot Engine v 3.2.3 с использованием его встроенного языка.

Думаю в целом его синтаксис ясен, однако оставлю ссылки на некоторые функции:

Система координат

На протяжении всей статьи мы будем работать только с правильными шестиугольниками, у них все стороны равны. Работа с неправильными шестиугольниками лишена смысла в принципе. Если не брать всякие повороты и искажения, существует два вида шестиугольных сеток, вертикально и горизонтально ориентированных:

  • Такие я буду называть вертикальными (у ячейки есть явный вертикальный сосед):

  • А такие горизонтальными (у ячейки есть явный горизонтальный сосед):

Уже на этапе введения координат могут возникнуть проблемы. Дело в том, что в шестиугольной сетке невозможно ввести типичную декартову систему координат - всегда будет ось, вдоль которой у ячейки не будет явного соседа. На самом деле существует огромное количество систем координат для таких сеток. Более подробно о них рассказано в упомянутых вначале статьях. Я рассказывать про каждую не буду, все таки пост больше про использование сеток, а не их исследование.

Пожалуй первое, что приходит в голову, это таки впихнуть декартовы оси, однако для этого по одному из направлений координаты придется смещать:

Такая система называется координатами смещения. Бывает два вида координат смещения - четные и нечетные. Они отличаются лишь выбором соседа для смещения. В моем примере использованы нечетные координаты смещения, т.е. ячейка смещается к нижней в случае вертикальной ориентации или к правой в случае горизонтальной.

Главная проблема этих координат состоит в выборе базисов. Для разных ячеек базис к следующей ячейке может отличаться от того, по которому мы к ней перешли. Если вы помните из статьи про прямоугольные тайлы, там мы обращали матрицу из базисных векторов и умножали на нее радиус-вектор пикселя, получая координаты ячейки. Здесь так не выйдет, провернув тоже самое мы просто получим квадратную сетку.

Вообще у сетки шестиугольников есть три ярко выраженных оси:

Что то напоминает, не находите? Тут три оси, прям как в пространстве. На самом деле гений тот человек - кто не просто увидел, что если взглянуть на куб под определенным углом, то получится шестиугольник, а еще и додумался использовать трехмерные координаты в двумерной сетке шестиугольников. Правда вот попробовав посчитать координаты на двумерной сетке, могут вскипеть мозги, ведь третья ось тут кажется лишней и ее использование будто только мешает. Для разрешения данной ситуации просто посмотрим откуда взялись кубы:

Для получения сетки шестиугольников надо взглянуть в изометрии на пирамидку, на каждом уменьшении высоты которой увеличивается количество кубиков в ряду на один. Включив фантазию, можно увидеть в каждом кубе шестиугольник. Включив внимательность, можно увидеть в левом нижнем углу координаты. Включив логику, можно увидеть, что сумма этих самых координат всегда равна некоторому числу, которое, кстати говоря, может быть любым, ведь мы сами решаем, откуда начнется система координат. В моем случае это 15, просто потому, что система отсчета в этом редакторе начинается с нижнего дальнего угла (в случае изометрического вида). Нам незачем таскать за собой лишнюю константу, поэтому возьмем ее за ноль. Таким образом, сумма трех координат всегда и везде равна нулю, поэтому третья нам попросту не нужна, ведь мы можем получить ее из двух других. Теперь и получается, что "мешающая" третья ось уходит. Наконец то мы можем спокойно направить базисы и у каждой ячейки всегда будет точный сосед, находящийся вдоль оси:

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

Преобразование координат

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

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

Оранжевые (маленькие) диагонали делятся пополам зелеными (большими), а зеленые оранжевыми, если последние провести из середины стороны. Уже получается, что мы можем разделить шестиугольник на 4 прямоугольника. Однако некоторые вершины в таком случае будут лежать где то между углами сетки, а ведь нам хотелось бы, чтобы все они попадали ровно в углы. На самом деле "где то", это ровно по серединке, поэтому разделим большие ячейки еще на пополам, тогда все вершины шестиугольника будут ложиться точно в углы прямоугольной сетки:

Желтую сетку в дальнейшем я буду называть вспомогательной. Для задания ее базисов будем использовать такие значения:

# Для горизонтальных шестиугольниковvar hex_size = 32var short = int(size*sqrt(3)/2) # 1/2 from short hex diagonalvar long = int(size/2) # 1/4 from long hex diagonal

Теперь мы можем выразить базисы шестиугольной сетки, используя базисы вспомогательной сетки:

Запишем все базисы в коде:

...# Transorm2D в godot - это матрица 3x2, где последняя строка указыает# смещение объекта, в дальнейшем она не будет использоваться совсем, # поэтому считайте это просто матрицей 2x2. Сделано это для удобства,# на объяснения никак не повлияет.# У нее есть два атрибута - x и y. Каждый из них это вектор. X - представляет# первый столбец матрицы 2x2 (крайняя строка не учитывается), Y - второй столбец.  var grid_basis = Transform2D() # Матрица базисов вспомогательной сеткиvar hex_basis = Transform2D() # Матрица базисов гексагональной сетки...  # Для вертикальной сеткиgrid_basis.x = Vector2(long, 0)grid_basis.y = Vector2(0, short)hex_basis.x = grid_basis.x*3 + grid_basis.yhex_basis.y = grid_basis.y*2# Для горизонтальной сеткиgrid_basis.x = Vector2(short, 0)grid_basis.y = Vector2(0, long)hex_basis.x = grid_basis.x*2hex_basis.y = grid_basis.x+grid_basis.y*3

Я пользуюсь именно встроенными средствами Godot для упрощения работы в целом. Все подобные места будут поясняться в общем виде, как бы это делалось без встроенных возможностей.

Шестиугольник в пиксель

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

func hex2pixel(hex):return hex.x*hex_basis.x + hex.y*hex_basis.y

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

Тогда для получения вершины в коде прибавляем нужный вектор (см. картинку выше) к центру шестиугольника. Я написал только функцию получения массива вершин, ибо по отдельности они почти никогда не нужны.

Для вертикальных шестиугольников:

func _get_vert_hex_vertices(hex):var pixel = hex2pixel(hex)return PoolVector2Array([pixel+2*grid_basis.x,pixel+grid_basis.x+grid_basis.y,pixel-grid_basis.x+grid_basis.y,pixel-2*grid_basis.x,pixel-grid_basis.x-grid_basis.y,pixel+grid_basis.x-grid_basis.y])

Для горизонтальных шестиугольников:

func _get_hor_hex_vertices(hex):var pixel = hex2pixel(hex)return PoolVector2Array([pixel+grid_basis.x-grid_basis.y,pixel+grid_basis.x+grid_basis.y,pixel+2*grid_basis.y,pixel-grid_basis.x+grid_basis.y,pixel-grid_basis.x-grid_basis.y,pixel-2*grid_basis.y,])

Пиксель в шестиугольник

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

Для горизонтальной ориентации

В коде это записывается так:

func pixel2hex(pixel):var x = pixel.x/(2*cw) - pixel.y/(6*ch)var y = pixel.y/(3*ch)return round_hex(Vector2(x, y))
Для вертикальной ориентации

В коде это записывается так:

func pixel2hex(pixel):var x = pixel.x/(3*cw)var y = pixel.y/(2*ch) - pixel.x/(6*cw)return round_hex(Vector2(x, y))

Однако я буду пользоваться функцией affine_inverse у Transform2D, для того, что бы при изменении базисных векторов постоянно не менять функции преобразований, позже увидите зачем это надо. Вы скорее всего работаете в другой среде (и зря), поэтому вам придется писать обращение матрицы самостоятельно. Кто не знает как это делается, или забыл, может почитать тут, или переписать следующие функции в свой язык:

Функции
func invert_basis(basis:Transform2D): # обращение матрицыvar det = basis.x.x*basis.y.y - basis.y.x*basis.x.yvar idet = 1.0/det# Я не уверен что Transform2D передается по значению, по этому# копирую данные в новый объектvar res = basisres.y.y = basis.x.x*idetres.x.x = basis.y.y*idetres.x.y = -basis.x.y*idetres.y.x = -basis.y.x*idetreturn resfunc vec_mul_basis(vec:Vector2, basis:Transform2D): # умножение вектора на матрицуvar x = vec.x*basis.x.x + vec.y*basis.y.xvar y = vec.x*basis.x.y + vec.y*basis.y.yreturn Vector2(x, y)func pixel2hex(pixel):return round_hex(vec_mul_basis(pixel, invert_basis(hex_basis)))

Средствами Godot это можно записать всего в одну строчку:

func pixel2hex(pixel):return round_hex(hex_basis.affine_inverse().xform(pixel))

Тут .xform(Vector2) - это метод для умножения матрицы на переданный в него вектор, аналог vec_mul_basis из моего кода. Такой код работает для обеих ориентаций.

Если вы хотя бы бегло прочитали вышеприведенный код, то наверняка заметили функцию round_hex вместо типичных приведений к int. Дело в том, что полных координат у шестиугольника 3, и они обладают условием x + y + z = 0, а после округления каждой из них равенство может нарушиться. Поэтому необходимо задать координату с наибольшей ошибкой округления через две другие, тогда условие выполнится. Да, данный метод полностью слизан отсюда, однако зачем придумывать велосипед, если можно взять готовый? Так же тут используется именно round, а не приведение к int, ведь основание каждой ячейки находится в ее центре, а не в левом верхнем углу, как в случае с прямоугольными сетками:

func round_hex(hex:Vector2):var rx = round(hex.x)var ry = round(hex.y)var rz = round(-hex.x-hex.y) # z = -x-yvar x_diff = abs(hex.x-rx) # Ошибка округления xvar y_diff = abs(hex.y-ry) # Ошибка округления yvar z_diff = abs(-hex.x-hex.y-rz) # Ошибка округления zif x_diff > y_diff and x_diff > z_diff:rx = -ry-rz # Приведение под равенствоelif y_diff > z_diff:ry = -rx-rz # Приведение под равенствоreturn Vector2(rx, ry)

Работает все замечательно:

Вертикальная ориентация
Горизонтальная ориентация

Однако я надеюсь вы не думаете, что сетки, это вручную нарисованные текстуры. Я не самоубийца.

Рисование сеток

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

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

const hex_map_size = Vector2(7, 7) # размер сетки шестиугольниковvar grid_map_size:Vector2 # размер вспомогательной сетки...grid_map_size.x = hex_map_size.x*2grid_map_size.y = hex_map_size.y*3+1

Для вертикальных шестиугольников все аналогично, только формулы для вычисления ширины и высоты меняются местами:

...grid_map_size.x = hex_map_size.x*3+1grid_map_size.y = hex_map_size.y*2

Сетку из шестиугольников можно разбить на две части, на вертикальные линии и на паттерн вершин:

Будем рисовать каждую составляющую по отдельности. Начнем с вертикальных линий. Можно заметить, что в каждом ряду линии рисуются с интервалом в 2 ячейки, а каждый четный по счету ряд начинается со второй, а не с первой ячейки. Также увидим то, что первый ряд начинается со со смещением в одну ячейку относительно верхей границы, а ряды разделяет одна ячейка. С учетом того, что длина штриха в две ячейки, между верхними концами отрезков находятся три ячейки. Тогда в цикле начинаем с единицы и идем до нижнего края карты с шагом 3, а во втором цикле начинаем со столбца, индекс которого обратен четности ряда, проще говоря 1-i%2, и идем до правого края карты, но на единицу больше, чтобы нарисовать таки крайние линии, с шагом в две ячейки. В кадой итерации второго цикла просто рисуем отрезок высотой две ячейки:

for i in range(1, grid_map_size.y, 3):for j in range(1-i%2, grid_map_size.x+1, 2):VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i, grid_basis.x*j+grid_basis.y*(i+2), color, width, antialiasing)

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

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

Для рисования паттернов пробегаем каждую третью строку, начиная с нулевой, а в каждой строке пробегаемся по столбцам. Тогда для выбора нужной линии сравниваем четности строки и столбца, если они совпадают, то рисуем нижнюю диагональ, иначе верхнюю. Тут я считаю нужным показать, как задается каждый угол ячейки с координатами {j, i} , где j - столбец (как бы x), i - строка (как бы y). Размер ячейки увеличен только для демонстрации:

В коде этот алгоритм выглядит так:

# Drawing verticesfor i in range(0, grid_map_size.y, 3): # рисуем на каждой третьей строкеfor j in range(grid_map_size.x): # крайний столбец не захватываем, т.к. в коде прибавляется единицаif i%2 == j%2: # нижняя диагональCanvas.line(surf, grid_basis.x*j+grid_basis.y*(i+1), grid_basis.x*(j+1)+grid_basis.y*i, color, width, antialiasing)else: # верхняя диагональCanvas.line(surf, grid_basis.x*j+grid_basis.y*i-offset, grid_basis.x*(j+1)+grid_basis.y*(i+1), color, width, antialiasing)

Однако просто нарисовав на холсте сетку, получатся непонятки с координатами:

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

Однако и на этом не все. Если просто объеденить весь код выше в одну функцию, то при четных высотах она будет рисовать ненужные хвосты:

Эти хвосты рисуются прямо в углах вспомогательной сетки, поэтому просто добавим уловие, что мы в них не находимся, иначе просто не рисуем тут диагональ.

Соеденив все вместе, получим такую функцию:

func _draw_hor_rect_grid(surf:RID, color:Color, width=1.0, antialiasing=false):var offset = grid_basis.x+grid_basis.y*2# Drawing vertical linesfor i in range(1, grid_map_size.y, 3):for j in range(1-i%2, grid_map_size.x+1, 2):VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i-offset, grid_basis.x*j+grid_basis.y*(i+2)-offset, color, width, antialiasing)# Drawing verticesfor i in range(0, grid_map_size.y, 3):for j in range(grid_map_size.x):if int(hex_map_size.y)%2 == 1 or not (i == grid_map_size.y-1 and (j == 0 or j == grid_map_size.x-1)):if i%2 == j%2:VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(i+1)-offset, grid_basis.x*(j+1)+grid_basis.y*i-offset, color, width, antialiasing)else:VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i-offset, grid_basis.x*(j+1)+grid_basis.y*(i+1)-offset, color, width, antialiasing)

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

func draw_auxiliary_grid(surf:RID, color:Color, width=1.0, antialiasing=false):var offset = grid_basis.x+grid_basis.y*2for i in grid_map_size.x+1:Canvas.line(surf, grid_basis.x*i-offset, grid_basis.x*i+grid_basis.y*grid_map_size.y-offset, color, width, antialiasing)for i in grid_map_size.y+1:Canvas.line(surf, grid_basis.y*i-offset, grid_basis.x*grid_map_size.x+grid_basis.y*i-offset, color, width, antialiasing)

И, как и обещал, функция для рисования вертикально-ориентированной сетки:

func _draw_vert_rect_grid(surf:RID, color:Color, width=1.0, antialiasing=false):var offset = grid_basis.x*2+grid_basis.y# Drawing horizontal linesfor i in range(1, grid_map_size.x, 3):for j in range(1-i%2, grid_map_size.y+1, 2):VisualServer.canvas_item_add_line(surf, grid_basis.x*i+grid_basis.y*j-offset, grid_basis.x*(i+2)+grid_basis.y*j-offset, color, width, antialiasing)# Drawing verticesfor i in range(0, grid_map_size.x, 3):for j in range(grid_map_size.y):if int(hex_map_size.x)%2 == 1 or not(i == grid_map_size.x-1 and (j == 0 or j == grid_map_size.y-1)):if j%2 == i%2:VisualServer.canvas_item_add_line(surf, grid_basis.x*(i+1)+grid_basis.y*j-offset, grid_basis.x*i+grid_basis.y*(j+1)-offset, color, width, antialiasing)else:VisualServer.canvas_item_add_line(surf, grid_basis.x*i+grid_basis.y*j-offset, grid_basis.x*(i+1)+grid_basis.y*(j+1)-offset, color, width, antialiasing)

Результат вполне неплох, нигде линии не рисуются дважды (сетка рисовалась немного прозрачной на черном фоне, а яркость линий везде одинакова):

Сетка вертикальных шестиугольников
Сетка горизонтальных шестиугольников

Однако рендерить такие сетки в реальном времени довольно затратно, тут рисуется множетсво отдельных отрезков, что сильно замедляет работу. Просто для примера, пустое черно окно у меня имеет fps около 950, а при рисовании белым цветом Color8(255, 255, 255, 200) шестиугольной сетки размера 10x10 и размером шестиугольнкиа 32 пикселя, fps примерно 260. Так что рисовать сетки процедурно резонно только на начальном этапе разработки, потом лучше отрендерить ее заранее и использовать как текстуру.

Рисование шестиугольной сетки шестиугольников

Сетки шестиугольников конечно здорово, но иногда хочется чего то большего. Например больше шестиугольников, поэтому сделаем большой шестиугольник из маленьких. Такая сетка может пригодиться, если мы, например, делаем шестиугольные шахматы.

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

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

var hex_map_size = Vector2(5, <не имеет значения>)...var diagonal = hex_map_size.x*2-1

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

...grid_map_size.x = diagonal*2grid_map_size.y = diagonal*3+1

Для вертикальных значения меняются местами:

grid_map_size.x = diagonal*3+1grid_map_size.y = diagonal*2

Шестиугольную сетку можно точно также разбить на две части, на паттерн вершин и вертикальные линии:

Начнем с рисования вершин. Рисовать каждый слой по-отдельности не имеет смысла, ведь фигура симметрична. Мы можем разделить всю вспомогательную сетку на четыре части и, нарисовав одну четверть, отобразить ее зеркально на все остальные. Сетка кстати всегда будет делиться ровно, и вот почему. По горизонтали понятно, ведь в формуле ширины мы удваиваем диагональ шестиугольной карты. А эта самая диагональ будет всегда нечетна, ведь мы от четного числа отнимаем единицу (hex_map_size.x*2-1). В формуле высоты вспомогательной сетки мы умножаем эту диагональ на 3, и результат получится тоже нечетным, а после прибавления единицы все выражение становится четным. Таким образом ширина и высота вспомогательной сетки всегда четны, и как следствие, ее можно всегда разделить на четыре одинаковые части:

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

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

Также вспомним, что каждый следующий паттерн рисуется со смещением в три ячейки от предыдущего, поэтому в цикле идем от нуля до половины высоты вспомогательной сетки с шагом в три, попутно вычисляя смещение для каждого ряда:

for i in range(0, grid_map_size.y/2, 3): # Drawing vertices  # тут i/3 потому что мы идем со смещением 3, а при расчетах нужен индекс  start = hex_map_size.x-1 - i/3  

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

for i in range(0, grid_map_size.y/2, 3): # Drawing vertices  # тут i/3 потому что мы идем со смещением 3, у при расчетах нужен индекс паттерна  start = hex_map_size.x-1 - i/3    for j in range(start, grid_map_size.x/2):  pass # Пока ничего не делаем

Каждый паттерн при рисовании шестиугольной карты четный, а вот смещение чередует свою четность. Четность начального смещения напрямую зависит от четности размера карты - они противоположны. Двигаясь вниз по рядам паттерна, индекс ряда меняет четность, как и смещение для этого ряда. Если помните, для выбора диагонали при рисовании прямоугольной сетки мы сравнивали четность ряда и колонки. Тут же меняются обе четности, и при разных раземрах карты они будут то совпадать при начальных значениях, то нет.

Приведу пример. Мы рисуем нижнюю диагональ, если индексы ряда и колонки совпадают, иначе верхнюю. Поставим размер карты 5. Тогда начальное смещение будет четным, как и индекс первого ряда (i=0). Исходя из условия, рисуем нижнюю диагональ, как и должно быть. Однако поставив четный размер, скажем, 4, начальное смещение будет нечетным, а вот индекс первого ряда по прежнему четным. Тогда взглянув на условие компьютер выберет верхюю диагональ, а ведь нам все еще для начала нужна нижняя. Вот как это будет выглядеть:

Тут на самом деле всего лишь надо поменять четность паттерна, тогда все встанет на свои места. Получается, выбор условия рисвания нижней диагонали зависит от четности самого размера карты. Тут можно заметить, что разница четностей столбца и ряда в каждой первой диагонали ряда паттерна обратна четности размера карты. А при рисовании паттерна диагонали просто чередуются, как и чередуется четность столбца, и как следствие чередуется равенство разностей четностей ряда и столбца и четности размера карты. Поэтому для выбора диагонали используем равентво abs(i%2 - j%2) != parity, где parity - это остаток от деления размера карты на два. Если это условие верно, рисуем нижнюю диагональ, иначе верхнюю. Получим то что нужно, осталось отразить по красным линиям:

Код рисования четверти всего паттерна
func _draw_hor_hex_grid(surf:RID, color:Color):var parity = int(hex_map_size.x)%2var startfor i in range(0, grid_map_size.y/2, 3): # Drawing verticesstart = hex_map_size.x - i/3 - 1for j in range(start, grid_map_size.x/2):if abs(i%2 - j%2) != parity:# Down diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(i+1), grid_basis.x*(j+1)+grid_basis.y*i, color)      else:# Top diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(i), grid_basis.x*(j+1)+grid_basis.y*(i+1), color)

Для отражения точек рисования отнимаем от края вспомогательной сетки индекс точки, ничего сложного. А вот в коде это выглядит громоздко. Взгляните сами:

func _draw_hor_hex_grid(surf:RID, color:Color, width=1.0, antialiasing=false):var parity = int(hex_map_size.x)%2var startfor i in range(0, grid_map_size.y/2, 3): # Drawing verticesstart = hex_map_size.x - i/3 - 1for j in range(start, grid_map_size.x/2):if abs(i%2 - j%2) != parity:# Down diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(i+1), grid_basis.x*(j+1)+grid_basis.y*i, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i+1), grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*i, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(grid_map_size.y-i-1), grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i), color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(grid_map_size.y-i-1), grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(grid_map_size.y-i), color)else:# Top diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(i), grid_basis.x*(j+1)+grid_basis.y*(i+1), color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i), grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(i+1), color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(grid_map_size.y-i), grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i-1), color)VisualServer.ca

Но ничего страшного, ковид пережили - переживем и это. Зато получаем правильный паттерн для сеток с четными и нечетным размером:

Если вы что то поняли во всей этой мешанине четностей, то либо вы гений, либо у меня получилось что то объяснить. Однако дальше не легче, но радует то, что мы почти нарисовали сетку. Осталось добавить вертикальные линии - это будет финальным боссом в рассказе о рисовании сеток.

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

for i in range(1, grid_map_size.y, 3):for j in range(1-i%2, grid_map_size.x+1, 2):VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i, grid_basis.x*j+grid_basis.y*(i+2), color, width, antialiasing)

Однако просто скопипастив его в нашу функцию, получим кривое рисование при четных размерах карты, ведь при них первый ряд должен иметь смещение в единицу, а при нечетных этого смещения быть не должно. Это вытекает из смещения первого шестиугольника в первом ряду, при четных значения оно нечетно поэтому и рисуем со смещением, и наоборот. Для выбора смещения сравним четности размера карты и ряда, если они отличаются, то рисуем без смещения, иначе со смещением. Пихать сюда условие не имеет смысла, ведь мы можем выбрать смещение через отличие четности карты и четности столбца конструкцией abs(parity-i%2). Просто напомню - parity это остаток от деления размера карты на два. Проверьте сами, при четных столбцах и нечетных размерах карты получается единица - то самое смещение. Запишем это выражение в смещение в цикле:

for i in range(1, grid_map_size.y, 3):for j in range(abs(parity-i%2), grid_map_size.x+1, 2):VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i, grid_basis.x*j+grid_basis.y*(i+2), color, width, antialiasing)

Цель почти достигнута, осталось избавиться от лишних линий по углам:

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

...start = hex_map_size.x-1 - i/3

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

...start = (i-grid_map_size.y/2)/3

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

for i in range(1, grid_map_size.y, 3):if i <= grid_map_size.y/2:start = hex_map_size.x-1 - i/3else:start = (i-grid_map_size.y/2)/3for j in range(abs(parity-i%2), grid_map_size.x+1, 2):if j >= start and j <= grid_map_size.x-start: # избавляемся от лишних линийVisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i, grid_basis.x*j+grid_basis.y*(i+2), color, width, antialiasing)

Вот и все - финальный босс побежден. Осталось только добавить смещение для расположения сетки в начало координат, offset = grid_basis.x+grid_basis.y*2. Однако тут опять играет роль четность размера карты, так что когда она четна прибавляем к смещению горизонтальный базис ячейки.

Босса то убили, а вот лут забыли. С него мы получили рисование шестиугольных сеток шестиугольников:

Горизонтальная ориентация
func _draw_hor_hex_grid(surf:RID, color:Color, width=1.0, antialiasing=false):var parity = int(hex_map_size.x)%2var offset = grid_basis.x+grid_basis.y*2 + grid_basis.x*(1-parity)var startfor i in range(0, grid_map_size.y/2, 3): # Drawing verticesstart = hex_map_size.x - i/3 - 1for j in range(start, grid_map_size.x/2):if abs(i%2 - j%2) != parity:# Down diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(i+1)-offset, grid_basis.x*(j+1)+grid_basis.y*i-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i+1)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*i-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*(grid_map_size.y-i-1)-offset, grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(grid_map_size.y-i-1)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(grid_map_size.y-i)-offset, color)else:# Top diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(i)-offset, grid_basis.x*(j+1)+grid_basis.y*(i+1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(i+1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)for i in range(1, grid_map_size.y, 3):if i <= grid_map_size.y/2:start = hex_map_size.x-1 - i/3else:start = (i-grid_map_size.y/2)/3for j in range(abs(parity-i%2), grid_map_size.x+1, 2):if j >= start and j <= grid_map_size.x-start:VisualServer.canvas_item_add_line(surf, grid_basis.x*j+grid_basis.y*i-offset, grid_basis.x*j+grid_basis.y*(i+2)-offset, color, width, antialiasing)

Пример:

Вертикальная ориентация
func _draw_vert_hex_grid(surf:RID, color:Color, width=1.0, antialiasing=false):var parity = int(hex_map_size.x)%2var offset = grid_basis.x*2+grid_basis.y + (1-parity)*grid_basis.yvar startfor j in range(0, grid_map_size.x/2, 3): # Drawing verticesstart = hex_map_size.x - j/3 - 1for i in range(start, grid_map_size.y/2):if abs(i%2 - j%2) != parity:# Down diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*(j+1)+grid_basis.y*(i)-offset, grid_basis.x*(j)+grid_basis.y*(i+1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(i)-offset, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i+1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(j)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)else:# Top diagonalVisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(i)-offset, grid_basis.x*(j+1)+grid_basis.y*(i+1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(i)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(i+1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(j)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(j+1)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)VisualServer.canvas_item_add_line(surf, grid_basis.x*(grid_map_size.x-j)+grid_basis.y*(grid_map_size.y-i)-offset, grid_basis.x*(grid_map_size.x-j-1)+grid_basis.y*(grid_map_size.y-i-1)-offset, color)for i in range(1, grid_map_size.x, 3):if i <= grid_map_size.x/2:start = hex_map_size.x-1 - i/3else:start = (i-grid_map_size.x/2)/3for j in range(abs(parity-i%2), grid_map_size.y+1, 2):if j >= start and j <= grid_map_size.y-start:VisualServer.canvas_item_add_line(surf, grid_basis.x*i+grid_basis.y*j-offset, grid_basis.x*(i+2)+grid_basis.y*(j)-offset, color, width, antialiasing)

Пример:

Рисование шестиугольников

Тут на самом деле все просто, можете расслабиться. После этой жести с четностями ничего хуже уже не будет. Для рисования шестиугольников вспомним функции для получения вершин, которые я приводил выше и просто соеденим массив вершин шестиугольника линиями:

Функции для получения вершин, если лень мотать неаверх
func _get_vert_hex_vertices(hex):var pixel = hex2pixel(hex)return PoolVector2Array([pixel+2*grid_basis.x,pixel+grid_basis.x+grid_basis.y,pixel-grid_basis.x+grid_basis.y,pixel-2*grid_basis.x,pixel-grid_basis.x-grid_basis.y,pixel+grid_basis.x-grid_basis.y])func _get_hor_hex_vertices(hex):var pixel = hex2pixel(hex)return PoolVector2Array([pixel+grid_basis.x-grid_basis.y,pixel+grid_basis.x+grid_basis.y,pixel+2*grid_basis.y,pixel-grid_basis.x+grid_basis.y,pixel-grid_basis.x-grid_basis.y,pixel-2*grid_basis.y,])

И рисуем множество линий между точками, не забыв замкнуть цепь:

func _draw_hor_hex(hex, surf, color, width=1.0, antialiasing=false):var points = _get_hor_hex_vertices(hex)points.append(points[0]) # замыкаемVisualServer.canvas_item_add_polyline(surf, points, [color], width, antialiasing)func _draw_vert_hex(hex, surf, color, width=1.0, antialiasing=false):var points = _get_vert_hex_vertices(hex)points.append(points[0]) # замыкаемVisualServer.canvas_item_add_polyline(surf, points, [color], width, antialiasing)

Для заливки шестиугольника, по аналогии с прямоугольником, рисуем полигон:

func _fill_hor_hex(hex, surf, color, antialiasing=false):var points = _get_hor_hex_vertices(hex)VisualServer.canvas_item_add_polygon(surf, points, [color], [], RID(), RID(), antialiasing)func _fill_vert_hex(hex, surf, color, antialiasing=false):var points = _get_vert_hex_vertices(hex)VisualServer.canvas_item_add_polygon(surf, points, [color], [], RID(), RID(), antialiasing)

Выгялдит все это как то так:

Шестиугольные сетки в изометрии

Что может быть лучше сетки шестиугольников? Правильно, сетка шестиугольников в изометрии. Вы могли заметить, что ни в одной функции я не использовал числа - везде я работал с векторами. А это значит, что поменяв базис, автоматически поменяются функции - так и должно работать программирование. Помните, в части про преобразование координат я все вычисления автоматизировал? Так вот, наконец то нам это пригодится.

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

...const iso_scale = 2.0

Тогда для изменения вида делим y-координату каждого базиса вспомогательной сетки на это искажение:

# Вертикальная ориентацияgrid_basis.x = Vector2(long, 0)grid_basis.y = Vector2(0, short/iso_scale)# Горизонтальная ориентацияgrid_basis.x = Vector2(short, 0)grid_basis.y = Vector2(0, long/iso_scale)

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

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

# для вертикальныхvar pw = int(long*cos(PI/4))var ph = int(short*cos(PI/4))grid_basis.x = Vector2(pw, pw/iso_scale)grid_basis.y = Vector2(-ph, ph/iso_scale)# для горизонтальныхvar pw = int(short*cos(PI/4))var ph = int(long*cos(PI/4))grid_basis.x = Vector2(pw, pw/iso_scale)grid_basis.y = Vector2(-ph, ph/iso_scale)

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

Красиво, конечно, но игру на этом не сделать. Нужно также уметь что то на этих сетках делать.

Изометрические преобразования

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

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

Посмотрев на нее, все сразу становится ясно. В случае горизонтальной ориентации при каждом перемещении по x на шестигольной сетке мы двигаемся на две ячейки вспомогательной, а при движении по y мы движемся на одну ячейку вправо и на три вниз. Для вертикальных применима эта же логика. По этим формулам написать функции можно проще простого:

# Для вертикальныхfunc get_center_cell(hex:Vector2):return Vector2(hex.x*3, hex.y*2+hex.x)# для горизонтальныхfunc get_center_cell(hex:Vector2):return Vector2(hex.x*2+hex.y, hex.y*3)

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

Расстояние на сетке

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

func hex_distance(hex1:Vector2, hex2:Vector2):var dif = (hex2-hex1)return (abs(dif.x) + abs(dif.y) + abs(-dif.x-dif.y))/2 # z = -x-y

Сеточное направление

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

Для нахождения направляющей разделим сетку по трем осям и заметим, что в каждой части получившейся сетки одна из трех координат максимальна по модулю:

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

func direct_hex(hex1:Vector2, hex2:Vector2):var dx = hex2.x - hex1.xvar dy = hex2.y - hex1.yvar dz = -hex2.x-hex2.y + hex1.x+hex1.yif dx == 0: # Ось yreturn Vector2(0, sign(dy)) # Возвращаем ось yelif dy == 0: # Ось xreturn Vector2(sign(dx), 0) # Возвращаем ось xelif dz == 0: # Ось zreturn Vector2(sign(dx), sign(dy)) # Возвращаем ось zelse:if abs(dz) > abs(dx) and abs(dz) > abs(dy): # модуль разности по z оказался наибольшимif abs(dx) > abs(dy): # т.к. разность по x больше, значит мы отошли по x дальше, чем по y, значит выдаем ось xreturn Vector2(sign(dx), 0) # возвращаем ось xelse: # т.к. разность по y больше, значит мы отошли по y дальше, чем по x, значит выдаем ось yreturn Vector2(0, sign(dy)) # возвращаем ось y        elif abs(dy) > abs(dx): # модуль разности по y оказался наибольшимif abs(dz) > abs(dx): # по аналогииreturn Vector2(0, sign(dy)) # возвращаем y. Это связанно с представлением z-координаты через две другиеelse: # по аналогииreturn Vector2(sign(dx), sign(dy)) # возвращаем z        else: # модуль разности по x оказался наибольшимif abs(dy) > abs(dz): # по аналогииreturn Vector2(sign(dx), sign(dy)) # возвращаем zelse: # по аналогииreturn Vector2(sign(dx), 0) # возвращаем x

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

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

Поиск пути

Основной алгоритм поиска пути тот же, что и у обычной прямоугольной сетки, отличаются только соседи и проверки на нахождение точки внутри карты. Главный алгоритм - A*, его не трогаем совсем.

Соседи у шестиугольника выглядят как то так:

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

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

Начнем с прямоугольной карты. Для наглядности напомню как она выглядит:

Синим обозначены границы карты. Оси в такой сетке идут не параллельно сторонам прямоугольника, поэтому просто ограничить их нулем и границей карты не выйдет. Так сработает только для Y оси сетки, а горизонтальные границы зависят от смещения по Y. Перемещаясь вдоль оси Y, расстояние до левой границы в ячейках вспомогательной сетки увеличивается на единицу, значит на половину шестиугольника. Аналогично с правой границей, тоолько до нее расттояние уменьшается. При округлении левой границы используем floor, т.к. когда граница проходит ровно между шестиугольниками, мы вмыбираем тот, что внутри. По аналогии используем ceil для правой границы:

func _in_rect_grid_hor(hex):return hex.x >= -floor(hex.y/2) and hex.x < hex_map_size.x-ceil(hex.y/2) and hex.y < hex_map_size.y and hex.y >= 0

Для вертикальной ориентации логика точно такая же. Вот функция для нее:

func _in_rect_grid_vert(hex):return hex.x >= 0 and hex.x < hex_map_size.x and hex.y >= -floor(hex.x/2) and hex.y < hex_map_size.y-ceil(hex.x/2)

Теперь про шестиугольную карту. Ее вид:

Для простоты вычислений будем считать границы от центра карты. Просто потыкав и посмотрев на координаты я пришел к следующим формулам центров:

# для горизонтальныхfunc _get_hor_hex_map_center():return Vector2(int((hex_map_size.x-1)/2), hex_map_size.x-1)# для вертикальныхfunc _get_vert_hex_map_center():return Vector2(hex_map_size.x-1, int((hex_map_size.x-1)/2))

Каждому смещению по Y соответствует уменьшение длины ряда на единицу, так и будем задавать границы по x. В качестве размеров, ограничивающих карту, возьмем диагональ. Как ее вычислять я рассказывал ранее. Тогда по Y границами будут просто половины этих диагоналей, а по X одна из граней всегда параллельна оси Y, поэтому уменьшаться будет либо правая либо левая граница. Какая именно можно понять по вертикальной половине шестиугольника. Если мы находимся в верхней, то уменьшается левая граница (т.к. правая параллельна оси Y), если в нижней то уменьшается правая граница (т.к. левая паралельна оси Y). А если мы находимся прямо на горизонтальной диагонали то нам плевать, ведь на ней смещение по Y равно нулю.

Вот функции, реализующие данную логику для обеих ориентаций:

# для горизонтальныхfunc _in_hex_grid_hor(hex):    var center = _get_hor_hex_map_center()    var diag = int(hex_map_size.x*2 - 1)    hex -= center # Vector2 passed by value; getting hex regarding map center    if hex.y < 0:        return hex.x >= -diag/2+abs(hex.y) and hex.x <= diag/2 and hex.y >= -diag/2 and hex.y <= diag/2    else:        return hex.x >= -diag/2 and hex.x <= diag/2-abs(hex.y) and hex.y >= -diag/2 and hex.y <= diag/2# для вертикальныхfunc _in_hex_grid_vert(hex):    var center = _get_vert_hex_map_center()    var diag = int(hex_map_size.x*2 - 1)    hex -= center # Vector2 passed by value; getting hex regarding map center    if hex.x < 0:        return hex.y >= -diag/2+abs(hex.x) and hex.y <= diag/2 and hex.x >= -diag/2 and hex.x <= diag/2    else:        return hex.y >= -diag/2 and hex.y <= diag/2-abs(hex.x) and hex.x >= -diag/2 and hex.x <= diag/2

Для проверки поставим условие, что для рисования шестиугольника под курсором он должен быть внутри сетки:

Отлично, теперь можно спокойно реализовывать алгоритм поиска пути:

Ищем путь истинный
class PriorityStack:var items:Arrayfunc _init():items = Array()func empty() -> bool:return items.size() == 0func put(item, priority:int) -> void:if empty():items.append([item, priority])elif priority <= items[0][1]:items.insert(0, [item, priority])elif priority > items[-1][1]:items.append([item, priority])else:for i in range(len(items)):if priority <= items[i][1]:items.insert(i, [item, priority])breakfunc take():return items.pop_front()[0]func in_map(hex):match grid_type:GridTypes.hex:if hex_type == HexTypes.hor:return _in_hex_grid_hor(hex)else: # Verticalreturn _in_hex_grid_vert(hex)GridTypes.rect:if hex_type == HexTypes.vert:return _in_rect_grid_vert(hex)else: # Hor orientationreturn _in_rect_grid_hor(hex)func can_stand(hex:Vector2, obsts:PoolVector2Array):return in_map(hex) and not (hex in obsts)func neighbors(hex_pos:Vector2, obsts:PoolVector2Array):var res:PoolVector2Array = []var _neighbors = PoolVector2Array([Vector2(-1, 0), Vector2(1, -1), Vector2(0, -1), Vector2(1, 0), Vector2(0, 1), Vector2(-1, 1)])for i in _neighbors:if can_stand(i+hex_pos, obsts):res.append(i+hex_pos)return resfunc find_path(start:Vector2, goal:Vector2, obsts:PoolVector2Array):var frontier = PriorityStack.new()frontier.put(start, 0)var came_from = {}var cost_so_far = {}came_from[start] = startcost_so_far[start] = 0var current:Vector2var new_cost:intif not can_stand(goal, obsts):return PoolVector2Array()while not frontier.empty():current = frontier.take()if current == goal:breakfor next in neighbors(current, obsts):new_cost = cost_so_far[current] + 1if not (next in cost_so_far) or new_cost < cost_so_far[next]:cost_so_far[next] = new_costfrontier.put(next, new_cost+hex_distance(goal, next))came_from[next] = currentif frontier.empty() and current != goal:return PoolVector2Array()current = goalvar path = PoolVector2Array([current])while current != start:current = came_from[current]path.append(current)path.invert()path.remove(0) # removes first positionreturn pathfunc hex_distance(hex1:Vector2, hex2:Vector2):var dif = (hex2-hex1)return (abs(dif.x) + abs(dif.y) + abs(-dif.x-dif.y))/2

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

Растеризация отрезка

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

Растеризуем нерастеризуемое
func rast_line(hex1, hex2):var N = hex_distance(hex1, hex2)if N == 0: return PoolVector2Array([hex1])var res = PoolVector2Array()for i in range(N+1):res.append(round_hex(lerp(hex1, hex2, i/N)))return res

Вот так это выглядит:

Пару слов в завершение

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

Если я забыл про что рассказать или упустил какие то важные моменты, или же просто ошибаюсь, напишете об этом в комментарии.

Я надеюсь эта статья позволит вам полностью реализовать давние мечты по созданию "убийы героев" или что она позволила просто интересно провести вечер. До скорого!

Подробнее..

Демо-версии Невангеров для Unigine и Godot

19.10.2020 22:04:09 | Автор: admin
Альтернативные прототипы с биомашинками (и не только био-), которые собрал за время знакомства с игровыми движками Unigine 2 и Godot 3.




Unigine engine



Начнём с версии для Unigine. Используется версия 2.11, вышедшая этой весной, начиная с которой в движке появилась бесплатная лицензия. На данный момент вышла 2.12 и скоро ожидается 2.13.

Что в общем стоит знать про Unigine это томский игровой движок, часто используемый для бенчмарков и симуляций. На нём в разные годы вышли такие игры как Oil Rush, Cradle, и вот, например, относительно недавняя ммо Dual Universe.
Внутри применяется довольно много интересных и перспективных решений, рендерит достаточно красивую картинку и может довольно сильно приглянуться художникам, особенно если те моделят в отдельном 3д-пакете, а не средствами самого движка.

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

В качестве инструмента для игрового разработчика здесь в принципе применим опыт использования C# в Unity, хотя в Unigine нет такого же многообразия готовых компонентных решений. Тем не менее, какие-то базовые вещи реализованы, а документация поможет написать остальное. С++ тоже никуда не делся.

Для мобильной платформы движок пока не подойдёт, как и для разработки без программирования (хотя планируется развивать его и в этих направлениях). Требования к железу на уровне запросов Unreal, минимальный вес файла приложения довольно большой. Зато красиво и неплохо оптимизировано.

Но вернёмся к игровому прототипу. В версии для Unigine основные сложности были связаны с визуальным конструированием физики для машинки, потому как в документации предлагается делать всё это вслепую, через код, к тому же всевозможные физические настройки разбросаны по разным местам редактора и сам пайплайн визуальной сборки не описан. То есть не было банального готового примера, который можно было бы модифицировать, не погружаясь во все нюансы. В 2.12 вроде появилось демо с копающим землю трактором, но, возможно, он опять собирается кодом, не смотрел этот момент.

Как бы то ни было, машинку я более менее собрал, ориентируясь на документацию. О чём ранее писал соответствующую статью: Как я собирал физику колёс в Unigine

Что касается геймплея, в Unigine, в отличие от Unity и Godot, например, оказалось довольно просто переключать физику машинки на лету, не только визуал, но и расположение/размер колёс. Без придумывания дополнительных трюков для того, чтобы не провалиться сквозь пол, и без пересоздания модели. Хотя, существует некоторый шанс провалиться в текстуры, но совершенно в иных ситуациях, а не в момент смены машинки.

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

С чем в движке печально, так это со сборкой UI из редактора это немного боль, со сбором кнопок практически вслепую. По крайней мере инди-разработчику собирать продвинутые макеты ui отдельно, в специальных форматах, как заведено в крупных студиях не очень хочется. С другой стороны, если игра более консольной направленности, про геймплей завязанный на небольшое количество кнопок, с минимальным использованием интерфейса и с минимумом интерфейсных элементов, то это перестаёт быть заметной проблемой.

Другая удручающая вещь, простые анимации, записанные на внутренний инструмент трекер. Да, он по-своему мощный, но какой-то слишком квадратно-гнездовой в использовании. Мало этого проиграть записанную таким образом анимацию можно только используя устаревший язык UnigineScript. Это в то время как в Unity или Godot можно заанимировать буквально всё вокруг по периметру. Да, можно импортировать анимацию костей, но это немного другое.
Если, опять же, взглянуть на это с другой стороны, то движок всё-таки больше ориентирован на физические симуляци и взаимодействия, так почему бы этим не воспользоваться вместо запекания каких-то там предустановленных анимаций то есть делать какие-то эмиттеры, переключатели, взаимодействующие конструкции, применять силы, использовать гравитацию и так далее. Так что без внутреннего аниматора можно обойтись, но если бы он был нормально реализован, то это тоже большое дело хотя бы даже в плане режиссуры в несколько кликов записать пролёт камеры по сцене со всякими интерактивными элементами, и вот тебе уже готовый фильм. Тем более, при таких возможностях для кинематографичной картинки.

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



Архив для Win64 можно скачать здесь (вес 687Мб): DROPBOX
или на страничке itch.io: NEWANGERS
в распакованном виде занимает 3Gb

Что тут есть:

* В билде три основных режима отображения дневной и ночной (с пресетами высоких графических настроек), и режим а ля комикс (с более низкими настройками графики).
Для переключения между режимами нужно нажать Esc, чтобы показался курсор мыши, и выбрать нужную опцию в менюшке слева сверху.

* На уровнях есть интерактивные объекты синие звездообразные порталы, проезжая в которые машинка переносится в другие миры и можно возвращаться обратно, проехав в портал с той стороны.

* Многие объекты уровня проницаемы, но у некоторых есть коллайдеры и сами уровни более менее окружены блокирующими стенками.

* Машинка в основном управляется кнопками WASD. Также можно стрейфиться по Q и E. Прыгать на пробел, или, наоборот, сильнее устремляться к земле нажимая R. По Tab машинку можно крутить, чтобы, например, перевернуться и встать на колёса.
Стрейфам и прыжкам не установлены лимиты, то есть во время прыжка можно прыгать дальше и так далее.

* Кнопки 1,2,3,4,5,6,7 переключают разные машинки. Колесом мыши можно слегка зумить камеру.

* По левой кнопке мыши можно заваливать всё вокруг небольшими блоками, а по правой кнопке мыши создаются светящиеся магические сферы.

* PgUp включает альтернативный режим колёс, поворачивая оси в другое положение. PgDown возвращает их в обычное состояние.

* P выход из игры, L перезагрузка с откатом на стартовый уровень

Более ранние исходники этого прототипа знакомый выложил на своей страничке вместе с некоторыми правками физики колёс первоначальной версии: GITLAB

Godot engine



Переходим к следующему движку в нашем списке. Опенсурсное компактное решение с огромными возможностями (Blender от мира игровых движков), но, опять же, пока уступающий тому же Unity в плане многообразия готовых компонентов. Хотя, различных неофициальных решений, а также примеров с исходниками для Годо уже написано довольно много и благодаря относительной простоте/быстроте имплементации нового функционала, не сказать, чтобы это было какой-то проблемой на данный момент.

У Годо больше репутация 2д-движка, благодаря многообразию проработанных инструментов именно для 2д, но они являются дополнительным плюсом для 3д куда проще делать игровой UI. Ещё проще чем в Unity, как по мне. На текущий момент Годо в своём развитии дошёл до стабильной 3.2.3 версии (но все ждут 4 из за вулкана, оптимизаций и так далее. шаткие сборки четвёрки, кстати, уже можно пробовать хотя бы оценить картинку).

Движок не требует мощного железа для 3д-графики и выдаёт вполне приличную картинку. Готовых трёхмерных инструментов не огромное количество, но реализованы как раз одни из самых нужных, полезных и универсальных. Примерно то же касается и оптимизаций. Например, в движке реализован обычный frustrum culling, отсекающий геометрию вне зоны видимости камеры. Occlusion culling (чтобы не считать закрытые стенами объекты) реализацию придётся придумывать самостоятельно (что не так уж сложно, особенно в каких-то точечных местах, да и не в каждой игре нужно). Также из коробки в движке нет батчинга геометрии (правда для gles2 частично есть) и террейна, но это не такая уж проблема, просто потребуется что-то оптимизировать вручную сшивать какие-то меши вместе, бить геометрию на мелкие части или использовать чанки и так далее. Можно подыскать какую-то реализацию в местном небольшом сторе, например, добавить в свой проект готовое решение для террейна.

Интерфейс движка, кстати, довольно продуманный и кастомизируемый (хотя есть некоторые негибкие элементы). Пользоваться им в целом удобно. Поддерживаемых языков достаточно, для разного уровня погружения. Тут и C++ и C# и довольно удобный внутренний GDScript, который запускается прямо внутри редактора, не требуя запуска отдельной среды. Визуальный скриптинг тоже присутствует, так что без знания программирования в Годо тоже вполне можно жить какую-то минимальную логику сконструировать, что-то заанимировать (в Godot есть простой и классный инструмент для записи анимаций).

Малый вес приложения, мультиплатформенность, быстрота разработки, простота имплементации различных сторонних решений тоже немаловажные плюсы движка. Есть два варианта рендера gles2 и gles3, оба поддерживают 3д, но в первом оно попроще и в целом он больше подходит для 2д и мобилок. Gles3 даёт более продвинутый уровень графики, какая-то часть мобильных устройств его тоже поддерживает.

Перейдём к игровому прототипу на этом движке. В Godot простая физическая модель транспортного средства идёт практически из коробки, поэтому перенести сюда свои машинки было довольно просто.

С чем возникли некоторые проблемы в запущенный физический объект не так просто вмешаться, чтобы, допустим, телепортировать его из одной точки в другую или на лету поменять расположение колёса машинки. Поэтому здесь такой трюк как в Unigine не работает и, как я понял, в Годо проще пересоздать объект, чем поменять ему на лету конфигурацию физики или что-то подобное. Но на самом деле это всё особо-то и не нужно, и в этом прототипе я просто вернулся к стандартной идее геймплея, когда катаешься за разные машинки, пересаживаясь из одной в другую где-то в специальных отдельных местах.

В Годо есть интересные инструменты, вроде CGS-объектов и мультимеша. Подробнее про особенности их использования я писал в статье: Godot, 1000 мелочей

Прототип Невангеров на этом движке получил отдельное название Wild Engines. В целом у меня получается как бы семейство сходных проектов, объединённых концепцией странных машинок путешествующих по странным мирам. И в качестве рабочего собирательного названия я их привык именовать Невангерами, пока не придумается более конкретное наименование. У Godot прототипа теперь появилось своё название, у приостановленной на версии 0.9 Unity версии (с которой всё и началось) тоже появилось другое название, но до этого дойдёт дело потом, если появится время к ней вернуться.

Особого видения о том, что хотелось бы реализовать в демке Wild Engines поначалу как-то не складывалось, просто делал наброски уровней, пытаясь понять как следует лучше реализовать террейн и какие вобще возможности хотелось бы видеть. Затем я пришёл к тому, чтобы сделать камеру двигающуюся за мышью и стало возможным реализовать более контролируемое прицеливание оружия, чем в прочих версиях. Таким образом данный прототип станет, вероятно, больше ориентирован на использование стрельбы.

Попутно стал собирать уровень из платформ, состоящих из маленьких блоков, пробуя различные подходы к оптимизации и замеряя производительность. Оптимизации оптимизациями, но в какой-то момент я уже настолько заполнил видимую на камеру область геометрией, что редактор перестал тянуть всю запущенную сцену, вываливась с ошибками. Что исправилось включением большего количества памяти в настройках. Тогда я сильнее увёл генерацию платформ в код, оставив на сцене только упрощённые габаритные блоки с идентификаторами, которые сами подгружали в себя нужную платформу при старте игры. После чего откатил количество используемой памяти к меньшему значению и уже без проблем увеличил уровень ещё в несколько раз. К тому же при такой реализации всегда остаётся вариант переиспользования уже подгруженных платформ, перемещая их в другие места уровня, когда машинка направляется в ту сторону, чтобы иметь возможность рисовать ими пространство уровня практически до бесконечности при тех же ресурсах и без переходов.
Примерно похожим образом я потом собрал наброски похожего уровня в Unigine, только там редактор сразу тянет всю сцену без надобности подгружать платформы в блоки через код (но для Unigine и компьютер используется куда более мощный).

В итоге в демо версии Wild Engines есть 4 машинки, одну из которых нужно выбрать для старта, и два небольших уровня (Level A и Level B). Пара ранних карт тоже осталась (Levels 0 и 1), но они ещё более тестовые и ландшафт там неоптимизирован.



В меню можно включить/выключить полноэкранный режим и тени.
Кнопки 1, 2 и 3 меняют позицию камеры. Мышь нацеливает и поворачивает камеру, тем сильнее, чем дальше курсор от центра.
WASD перемещение. PgDown прыжок. Q случайный импульс.
Левая кнопка мыши выстрел.
По Enter появляется подсказка об управлении и кнопка возврата в основное меню, где можно поменять машинку/уровень.

Win64 версия (42 Mb): DROPBOX wildengines_x64
Linux версия (44 Mb): DROPBOX wildengines_linux

Бонус



А вот один из недавно появившихся новых мехосов, Некромант:

На нём можно покататься в версии для Unigine.
Другой новый мехос, розовый с синей подсветкой, Дебаг, есть и там и там.

Ещё тут завалялся один старый картридж как по вашему, клоны каких восьмибитных игр могли скрываться под таким названием на китайском картридже, будь Вангеры всемирно известной франшизой в те далёкие времена?

Подробнее..

Микрокосм, демоверсия

23.10.2020 18:08:20 | Автор: admin
Всем доброго дня, в какой бы галактике вы не находились.
После череды итераций прототип космической jrpg, разрабатываемый на Godot engine, дорос, наконец, до первой демоверсии. Доступны win64 и linux варианты. Ниже подробности о том, что было, что стало и куда летает маленький звездолёт.


Каркас


Наброски базового геймплея сформировались довольно быстро, когда я перешёл от продумывания основных положений концепции к сборке игрового прототипа внутри игрового движка. Сначала появился стартовый экран, сцена глобальной карты с движением и возможностью вызова инвентаря. Тогда же была продумана некоторая начальная архитектура как именно грузить отдельные модули и обмениваться информацией между ними.
Затем появился экран сражения, запускаемый сначала по отдельной кнопке, а позднее привязанный к столкновению с врагами. Позже добавился экран планеты, у планет к тому времени появился информатор на глобальной карте, куда и была добавлена кнопка посещения. От информатора в свою очередь отпочковались прочие виды интерактивных объектов, например станция, не подразумевающая входа на свой внутренний экран, но иногда имеющая какой-то внешний функционал с возможностью что-то нажать или выбрать. Или подбираемые ресурсы космическая пыльца.
Далее требовалось реализовать максимально возможную партию героев (выбирая между 3-мя либо 4-мя, я выбрал всё-таки концепцию партии из 3-х), что и было сделано. В процессе добавилось отображение повреждений, выбор целей для атаки, немного музыки.
На этом этапе можно было уже выкладывать мини-демку, но я решил сначала доработать проект до более комплексного состояния, чтобы было лучше понятно, что это вобще такое и как оно примерно должно работать. Попутно добавив немного контента и каких-то украшательств, типа озвучки действий, анимаций.

Почитать подробнее про концепцию и базовую реализацию можно в этих статьях:
Microspace project
Итеративный геймдизайн, Godot и мир маленьких планет



Развитие прототипа


Рассмотрим различные области, в которых происходило это развитие:

Бой

Количество доступных кораблей понемногу увеличивалось, как и количество доступных врагов, поэтому я шаг за шагом переписывал их боевое представление и управляющую логику в более универсальный вариант. Что было не так уж сложно, благодаря тому, что подобный ход событий был предусмотрен и что-то в коде оптимизировалось заранее.

Отображение повреждений поначалу было реализовано через 3д-представление вывод вьюпорта со строкой урона на обращённый к камере полигон. Это более менее работало, хотя реализация технически мне не очень нравилась, так как с вьюпортом всё немного заморочено нужно вешать его поближе к корню сцены, чтобы не возникало сбоев и проверять как выглядит эффект чуть сложнее, чем с просто выводимой 2д-строкой. Поэтому много вьюпортов заводить не хотелось, я обходился всего одним, перемещая выводящий его полигон куда нужно, попутно увеличивая/уменьшая. А ведь надо думать и о том, что когда-то появятся способности, наносящие массовые повреждения и потребуется выводить как минимум три значка с уроном одновременно (хотя и тут можно исхитриться, сделав как бы анимацию-волну из одного и того же объекта или уж завести ещё пару вьюпортов).
Однако, когда я добавил в прототип режим полного экрана, то там цифры повреждений стали заметно так размываться, поэтому ещё сильнее захотелось рассмотреть иные варианты отображения урона. В принципе я мог бы просто фиксировать камеру в бою, но я зачем-то продолжаю до упора сохранять возможность вертеть ею в моменты между ожиданием действия.
Поэтому в итоге я переписал вывод урона. Теперь 2д-строчки цепляются к проекции позиции 3д-объекта. Хотя тут тоже есть свои нюансы, например, если отвернуть камеру, то они продолжат показываться или если их выводить беспрерывно в каждом кадре, то есть возможность подвесить текущую камеру в одном положении, но это уже решаемо.

Для ускорения процесса тестирования давно была сделана специальная, как-бы неигровая, кнопка автоматической победы, если требовалось выйти из боя. Также долгое время не было возможности проиграть. Потом я добавил замораживание корабликов при обнулении их полоски и далее уже напрашивалась полноценная реализация проигрыша сражения. Для этого я сначала перевёл кнопку автопобеды в разряд полноценных игровых опций, с той лишь разницей, что теперь это стало экстренной кнопкой автоматического проигрыша, запускающей диалог выхода из боя. А затем дописал провырки для автоматического завершения сражения при нулевой прочности всех корабликов, также вызывающие этот диалог.
После поражения в сражении враг исчезает, а прочность каждого кораблика партии в следующем сражении будет равной 10. Кстати, верхней границы прочности пока нет и различным лечением можно повышать прочность сверх базовых значений.

Корабли и враги

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

Противников тоже прибавлялось, к тому же с разными подходами. Один враг, пара разных врагов, пара одинаковых, более крупный враг с двумя атаками, мини-босс состоящий из нескольких частей.

У четырёх корабликов есть личные способности, которыми можно пользоваться в сражении. Это Скиталец (способность наносит урон и даёт прибавку к следующей атаке), Спира (улетает в будущее, на ход вперёд, получая по возвращении бонусный урон к следующей атаке), Тринити (просто наносит урон) и Отомо (раскладывает на всю партию лечащие прибавки к следующим атакам).
На применение способностей тратится некоторый процент энергии корабля (на данный момент эта энергия ничем не восстанавливается). У прочих кораблей на месте личной способности установлена способность-заглушка Щит, играющая анимацию, но не имеющая эффекта.

Способности и персонажи

Если корабли являются неким аналогом главных героев, то перевозимые ими персонажи являются неким аналогом оружия этих героев. Одним из важных моментов, который хотелось отразить в проекте это использование системы идентификаторов, которые дают эффект неслучайной случайности, а при более широком использовании могут работать на генерацию псведо-сюжета. Говоря по-простому, у персонажей, кораблей и врагов есть специальные ID, позволяющие рассчитать результат взаимодействия любых этих объектов и как-то его использовать.
На данный момент использование этого результата следующее. Во-первых, у каждого персонажа и корабля возникает эффект совместимости. То есть, оказавшись на борту корабля, персонаж может, например, начать паниковать, уснуть, или не понимать как обращаться с его системами. Сейчас просто в окошке инвентаря выводится результат связи корабль-пассажир и возможный дополнительный эффект, ею накладываемый. Но на участии персонажа в бою это пока никак не отражается.
Непосредственно в сражении персонажами можно атаковать через опцию Экипаж. Это не просто совершение персонажем какого-то конкретного действа, вроде конкретного магического заклинания или выстрела из оружия. Это некая ситуация, повлекшая за собой определённое количество потерь со стороны противника. Здесь работает уже связь пассажир-враг, которая интерпретируется окружающим миром (в данном случае космическим сектором) в одно из девяти последствий: #СВЕТ, #ТЬМА, #МУЗКА, #ТЕОРИЯ и так далее. То есть игрок может ассоциативно представить, что сделал персонаж, что получилось последствие с такой формулировкой (а может и не представлять ассоциации дело необязательное, тем более они всё-равно сами по себе работают где-то в фоне). У каждого врага соответственно есть уязвимости или стойкость к определённого вида последствиям. Возможно, в дальнейшем применение системы идентификаторов ещё более разрастётся. Например, на тех же планетах могут действовать их собственные ауры смыслов, видоизменяющие таблицу интерпретаций, но сначала нужно реализовать там какие-то события, в которых участвовали бы идентификаторы.

Некоторые редкие персонажи обладают активными способностями для кораблей определённого типа. Кстати, пока не у всех таких персонажей подписано, что они дают какую-то способность (в основном это касается тех, которых сейчас нет в основном режиме игры).
Как бы то ни было, на данный момент способности, получаемые от перевозимых персонажей, всего-лишь проигрывают эффект-пустышку (как и большинство предметов). Зато у одного героя, Лето, есть пассивная способность, без привязки к типу корабля, и она уже работает.
Спира и Скиталец кроме этого могут изучать способности персонажей своих типов (хотя у них пока и отсутствует эффект), проведя с ними одно сражение.

Предметы

До грузов всегда дело доходило во вторую очередь, после того как уже была готова реализация для персонажей. В основном всё сводилось к тому, чтобы показывать их ячейки и перемещать заглушки предметов между ними. Но позднее появился активируемый предмет, Жива, восстанавливающая 100 прочности корабля. К тому моменту я как раз начал делать боевую опцию Трюм и требовался хотя бы один предмет с каким-то реальным эффектом.
Что касается самого лечащего предмета, то изначально планировалось, что может быть у него будут какие-то заряды и их можно будет где-то восполнять. В итоге я оставил один заряд и сделал так, что этот предмет умеет разряжаться при использовании и заряжаться снова от собранной космической пыльцы.

Экран планеты и задания

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

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

Прочее

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

В некоторые световые дни теперь появляются специальные уведомления. Сами эти дни проматываются посещением любой планеты (в дальнейшем нужно будет летать между разными, для смены дня).

Постепенно были улучшены шрифты некоторых элементов. Появилось некоторое количество звуков для боя, для экрана планет, для инвентаря.

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

Демоверсия


Уже непосредственно перед появлением демоверсии для неё был создан режим последовательного развития, как основной вариант игры здесь игрок получает один стартовый кораблик (Скиталец) и начинает исследовать сектор, открывая новые звездолётики для присоединения к партии. Таким образом в этом режиме можно открыть ещё два кораблика (Спира и Авангард). В стартовом секторе есть три планеты, которые можно посетить. Починив специальную станцию можно переместиться во второй сектор и найти там ещё одну доступную планету.

Ранний тестовый вариант локации, где различные планеты и враги размещены более плотно, доступен в альтернативном, менее отлаженном, режиме игры all ships test. Там сразу доступен выбор из 10 существующих кораблей (в активную партию можно брать до трёх), больше героев/грузов и распределены они иначе. Могут возникать некоторые наложения внутриигровых параметров после выхода из одного режима и переключения на другой.

Нововведения последнего обновления показаны в новом ролике, где помимо геймплея добавлены элементы тизера.

Видеонарезки некоторых предыдущих обновлений








Загрузить архив демки можно здесь (dropbox):

Win64 версия (50 Mb)

Linux версия (52 Mb)

Управление:

WASD полёт, мышь вращение камеры
Enter открыть/закрыть инвентарь во время полёта в космосе


Кораблики: 1 Скиталец, 2 Спира, 3 Авангард, 4 Дева Яга, 5 Мухх, 6 Стелла, 7 Тринити, 8 Отомо, 9 Аквамарин, 10 Гиибель.

Подробнее..

Твоя первая игра на Godot Engine

02.12.2020 18:09:40 | Автор: admin

1. Предисловие

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

2.Стартуем!

Думаю установить сам движок не составит труда. После установки открываем его и нажимаем на кнопку новый проект.

Создание проекта.Создание проекта.

В выплывшем окошке введи название проекта и выбери его расположение в файловой системе. В пункте отрисовщик выбираем OpenGL ES 3.0, у нас нет нужды использовать более старую версию opengl, т.к ее обычно применяют при создании браузерных игр.

3.Знакомство с интерфейсом

2D сцена в Godot Engine.2D сцена в Godot Engine.

Итак, мы создали твой первый проект! Отличное начало, на сегодня хватит. Ладно, а если серьезно, то изучать интерфейс программы, особенно на первых парах, очень важно. Перед тобой открылась интересная картина с пустой 3d сценой, но она нам сегодня не понадобится, поэтому переходим в вкладку 2d. Кнопка находится сверху посередине. Стало немного проще, не правда ли? Ну, а теперь перейдем к самому интерфейсу программы (его кстати можно настроить под себя, перетащив какие-то элементы левой кнопкой мыши, но пока лучше оставит все как есть).

4.Работа с файлами через Godot

Не очень удобно каждый раз лазить в проводник, чтобы достать какие-то файлы, согласен? Для этого в Godot существует отдельное окошко, оно находится в левом нижнем углу редактора.

Проводник в Godot Engine.Проводник в Godot Engine.

Пока что там пусто, но это потому, что мы еще ничего не добавили. Так давай сделаем это! Ссылку на архив я прикрепил в конце поста, скачивай его и наслаждайся халявными спрайтами для игры (сразу говорю, они не мои). Если конечно у тебя заготовлена своя графика, то милости прошу.Итак, выделяем все нужные нам файлы и переносим их в окошечко. Вжух и они скопировались в движок! Но на этом магия не заканчивается! Здесь можно делать все то, что ты делаешь в обычном проводнике! Одним словом, полная свобода действий.

Но давай пока оставим эту часть экрана в покое и перейдем к более серьезным вещам.

5.Работа со сценами

Создание новых сцен.Создание новых сцен.

Посмотри в верхний левый угол редактора. Здесь есть вкладка, которая называется Сцена. Давай добавим твою первую сцену! Делается это просто, тебе нужно либо нажать на плюсик, либо ввести сочетание клавиш ctrl + A. Перед тобой выплыло меню, в поиске которого нужно ввести заветное слово Node. Мы давай это будет наша основная сцена, назовем ее World, но название в принципе неважно. Чтобы переименовать сцену нужно лишь дважды щелкнуть на нее левой кнопкой мыши. Теперь давай добавим на сцену игрока!

Многие просто добавляют объект Sprite, но это большая ошибка! Так делать нельзя! Запомни это раз и навсегда! Мы с тобой, как продвинутые пользователи добавим не Sprite, а KinematicBody2D.

Теперь древо твоего проекта выглядит так:

Добавляем игрока.Добавляем игрока.

Как ты наверное успел заметить, напротив нашего KinematicBode2D висит какой-то желты значок. Что он тут забыл? Дело в том, что наш объект пока что не имеет форму, вот Godot и ругается. Но прежде чем добавить форму нашему игроку, давай добавим его спрайт( И не забудь заменить название KinrmaticBode2D на Player ). Для этого нажми один раз правой кнопкой мыши на нашего Player и сочетанием клавиш ctrl + A добавь объект Sprite. Потом опять нажми на Игрока и добавь объект CollisionShape2D. У тебя должна быть примерно такая картина:

Добавляем в спрайт и границы игрока.Добавляем в спрайт и границы игрока.

Если все так, едем дальше. Теперь зададим картинку спрайта нашего персонажа. Выбираем объект Sprite, а потом перетаскиваем из моего архива картинку Player.png( или твою картинку) в раздел Texture. Если картинка импортировалась с сжатым качеством, просто нажми на нее, и в Godot в верхнем левом углу перейди в вкладку Импорт, там в разделе Flags убери галочку с пункта Filter и нажми Переимпортировать. Если не помогло, то просто перезапусти Godot.

Итак, мы добавили спрайт игрока, но выглядит это немного странно.

Добавляем текстуру спрайта игрока.Добавляем текстуру спрайта игрока.

Что же делать? Без паники, все поправимо в пару кликов. В левой части панели Инспектор выбираем параметр Hframes, и подгоняем его по размерам ( у меня это 25). Ну что, поменялась картинка?

Устанавливаем границы спрайта.Устанавливаем границы спрайта.

Супер, едем дальше! Ты еще не забыл про CollisionShape2D? Выделяй его и в пункте Shape выбирай Новый RectangleShape2D. Теперь изменяй его под размер персонажа. У меня получилось так:

CollisionShape2d.CollisionShape2d.

6.Отдельные сцены в Godot

Это все конечно классно,но хорошим тоном в Godot является создание отдельных сцен для объектов. Поэтому нам нужно сделать так, чтобы объект Player был отдельной сценой. Но не создавать же нам все заново? Нет, для этого в движке предусмотрена отдельная функция.Нажмите на Player правой кнопкой мыши и выберете Сохранить ветку,как ветку.

Создание сцены из ветки.Создание сцены из ветки.

Теперь Player это отдельная сцена, отлично!Чтобы перейти на сцену игрока достаточно нажать на иконку:

Перейдем на сцену игрока и приступим к очень интересному занятию программированию.

7. Скрипт игрока, GDscript

Для того чтобы добавить скрипт какому-либо объекту нежно просто выбрать этот объект и нажать на иконку свитка:

Создать скрипт.Создать скрипт.

После этого выплывет такая табличка:

Скрипт для игрока.Скрипт для игрока.

Нажимаем Создать и у нас открывается встроенный редактор кода в Godot.Теперь начинается более сложная часть туториала, поэтому слушай внимательнее.Пока что наш персонаж просто стоит на сцене и ничего не делает, это слишком скучно. Ну так давай сделаем управление персонажем!Что нам для этого понадобится? Нам нужен скрипт, который будет обрабатывать нажатия клавиш с клавиатуры, двигать персонажа, проигрывать анимацию. Но давай пойдем по порядку и начнем с самого простого управления.

Простое управление.Простое управление.

Пишем вот такой код, не волнуйся сейчас все объясню. Первая строчка объявляет Godot, что мы используем объект KinematicBody2D. Ее создал сам движок. На 3 и 4 строчке мы задаем две константы, отвечающие за ускорение и максимальную скорость. Они нужны для плавного перемещения персонажа по сцене. На 6 строчке объявляем переменную для вектора перемещения. После этого на 8 строчке создаем функцию physicsprocess, это системная функция движка. Она нужна, чтобы привязать к персонажу физику. В нашем случае - это физика перемещения и сила гравитации. 9 строчка отвечает за управление по оси X. Метод Input помогает нам считывать те самые кнопки для управления (стрелка влево и стрелка вправо). После на 11 строчке мы проверяем была ли нажата какая-то кнопка. Потом мы перемещаемся влево или вправо.

Как ты заметил, мы прибавляем к координате игрока произведение направления по координате на ускорение и на какую-то delta. Вопрос, что такое delta? Delta показывает сколько времени (в секундах, тип float) прошло с момента отрисовки прошлого кадра.Зачем это сделано? Если мы не будем привязывать передвижение игрока ко времени, то оно автоматически привязывается к частоте процессора. На крутых компьютерах или телефонах разница незаметна, но запустив приложение на старом пк или телефоне, ты все поймешь. Поэтому всегда привязывай передвижение к delta!

Потом в строке 13 мы используем какой-то clamp. Опять неразбериха! Все просто, clamp, как можно догадаться из названия, сжимает значение переменной. Сделано это для оптимизации и плавности движения.Ну и в последней строке мы просто запускаем передвижение нашего игрока. Не так уж все и сложно!

8. Первый запуск.

Вот сделали мы все это с тобой, а где результат? Ну так давай поскорее запустим с тобой первую демку! Все очень просто, нажми клавишу F5, после этого выплывет окно, которое скажет тебе, что основания сцена не выбрана. В нашем случае основная сцена World.tscn. Выбираем ее и снова жмем F5. Должно появиться что-то такое:

Окно демки.Окно демки.

В верхнем левом углу можно заметить маленькую часть нашего персонажа. Давай приведем все в порядок. Для этого сначала закрое окошко демки и перейдем в настройки проекта. Чтобы это сделать, в левой верхней части нажми на Проект, а в выплывшем окне нажми Настройки проекта. Здесь переходим в вкладку Window и ставим разрешение на 320x180. Почему такое маленькое? Все просто, мы с тобой задали разрешение экрана в самой сцене, для платформера такие размеры идеальны. А для экрана самой демки нужно задать нормальное разрешение. Это можно сделать в пунктах Test Width и Test Height. Я задам его в формате 1280x720. Спустимся пониже и в пункте Mode ставим 2d, а в Aspect ставим keep. Для красоты предлагаю обратно перейти на сцену и передвинуть персонажа в середину экрана. Делается это легко, просто зажми персонажа левой кнопкой мыши и начни перетаскивать. Теперь все приготовления закончены,можно запускать демку.

Красота, правда? Попробуй передвигать персонажа стрелками на клавиатур. Получилось? Да, но персонаж будто скользит по экрану и не может остановиться. Не волнуйся, скоро мы это исправим. Однако сейчас наш персонаж как бы висит в воздухе, это ней есть хорошо. Давай сделаем землю для нашего игрока!

9.Tilemaps

Что такое Tilemap? Тайлы это плитки, вместе образующие сетку тайлов. Чаще всего они принимают форму квадратов. Как же их добавить в наш платформер? Очень просто, для начала выбери объект World(нашу основную сцену), нажми клавиши ctrl + A и выбери TileMap.

Теперь в этом окошке выбири Tile Set и нажми Новый TileSet.Снова нажми на TileSet, должно получиться ка-то так:

Добавляем анимацию.Добавляем анимацию.

Давай добавим спрайт для нашего tilemap, для этого нажми на плюс снизу и выбери tile.png.

Следующий шаг будет довольно сложным, поэтому слушай внимательно.Итак, в вкладке Регион полостью выделяем нашу картинку, в вкалдке snap options ставим step по x и y на 16. Такие же действия повторяем в вкладках столкновение, перекрытие, навигация, битовая маска. А последней мы остановимся поподробней.

В ней мы нажимаем на квадратик и выделяем весть тайл. Тоже самое проделываем и в других вкладках. Вот как в итоге должно получиться.

Задаем границы тайла.Задаем границы тайла.

Отлично, сохраняем все и переходим обратно на сцену. Еще рах кликаем на Tilemap и в раздеел Cell меняем size на 16x16.Теперь можно делать уровень!

Создаем простой уровень.Создаем простой уровень.

Вот как у меня получилось. Супер, но на нашего игрока до сих пор не действует гравитация, давай это исправим. Для этого перейдем в скрипт player и введем там такой код.

Константы для прыжка и гравитации.Константы для прыжка и гравитации.

Здесь к существующим переменным мы добавляем friction, gravity, jumpforce, airresistance. Названия говорят сами за себя, поэтому объяснять за что они отвечают я не буду.

Реализация прыжка и гравитации.Реализация прыжка и гравитации.

Следом идет сама сила гравитации. Мы прибавляем к motion.y силу тяжести, умноженную на delta. Это действие заставляет нашего игрока падать вниз, если под ним ничего нет. После этого скрипт обрабатывает нажатия на кнопки, характерные для прыжка (стрелочка вверх). И заставляет игрока падать вниз, когда он уже прыгнул.

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

Как выглядит при запуске.Как выглядит при запуске.

10.Анимации

Простые анимации в Godot сделать очень легко. Для этого перейдем на сцену player и добавим туда AnimationPlayer. Жмем на кнопку анимация, далее жмем новый и вводим название анимации. Сделаю анимацию для бега и назову ее Run.Чтобы добавить новый кадр для анимации нужно перейти в sprite.

Добавляем кадры в анимацию.Добавляем кадры в анимацию.

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

Создаем анимацию из кадров.Создаем анимацию из кадров.

Тоже самое повторяем для других анимаций, просто меняя номер кадра. В итоге у тебя будет несколько анимаций для прыжка, ходьбы, и анимация idle. Теперь все это нужно добавить в код.Переходим в код player и вносим некоторые изменения:

Добавляем переменные для анимации.Добавляем переменные для анимации.

Добавляем две переменные sprite и player. Но ты заметил, они какие-то странные. Почему в начале стоит слово onready, что за странное значение этой переменной? Сейчас все объясню. Переменные типа onready нужны для взаимодействий с другими объектами на сцене. В данном случае мы подключаем их для воспроизведения анимации и получения спрайта игрока.

Анимация при ходьбе.Анимация при ходьбе.

На 16-ой строчке мы проигрываем анимацию ходьбы. Однако здесь еще появилась какая-то странная 22 строчка, что она делает? Она зеркально отражает анимацию игрока в зависимости от того, куда он идет. А на 24 строке мы говорим, что если игрок стоит, то проигрывать нужно анимацию idle.

Анимация прыжка.Анимация прыжка.

На 22 строчке мы проигрываем анимацию прыжка, если игрок не на земле. Вот собственно и все изменения в коде.

Заключение

Если ты все правильно делал, то у тебя должен получиться простой платформер. Что делать дальше? Да все что угодно! Улучшай свои навыки в использовании движка, создавай свои собственные игры изучай новые фишки. А этом я вынужден с тобой проститься, надеюсь ты хорошо провел время и научился чему-то новому.

Вот все материалы для этого туториала:

Подробнее..

Биом, демоверсия игры на Godot

18.12.2020 20:18:20 | Автор: admin
Собрал небольшую демку для win и linux. Этот экспериментальный прототип фокусируется на игре с видом сверху, и реализации системы бесконечного уровня в движке Godot. Биомашинки в комплекте.


Проект Биом задуман как аркадное путешествие по открытому миру/мирам, на биологическом или технологическом средстве передвижения. Конкретно в этой игре камера закреплена сверху и различные игромеханики рассчитаны на такой ракурс (хотя для записи видео и тестов я иногда использую нестандартный вид). Помимо чисто технических плюсов и минусов использования разных камер, разные ракурсы подразумевают и немного разный подход к организации геймплея.

Архитектура уровня



В прототипе реализована система бесконечного уровня (пока только вдоль одной оси). На текущий момент доступен один уровень, по которому можно бесконечно ехать в двух направлениях, а прочие блокируются двумя стенами.

Пара слов о том, как это устроено (в принципе довольно базовые вещи, никаких особых откровений). Текущий игровой мир состоит из двух зон (условно зелёная и синяя), каждая внутри себя разбита на 4 участка. Когда машинка задевает триггер определённого участка, то уровень проверяет требуется ли переложить одну из зон так, чтобы все выходы из данного участка вели к прочим участкам, а не в пустоту.


Игрок касается участка, прохождение которого выведет его за внешнюю границу уровня, и вторая зона перекладывается в направлении движения игрока, чтобы его снова окружали другие доступные участки.

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

После тестов дополнительно пришлось исправить такой нюанс, что триггеры участков плотно примыкают друг к другу и могла произойти ситуация, когда машинка только касается нового триггера, не выходя из старого (то есть зона перекладывается новым триггером, а старый не был активирован). Этот момент был исправлен тем, что триггеры стали учитывать и вход и выход машинки, перекладываясь только при выходе из зоны триггера и только тогда, когда выход случился не из той же самой зоны, которой только что коснулись (устраняя тем самым проблему касаний). В целом, использование триггеров зон в Godot это, разумеется, далеко не единственный способ реализации подобной перекладки уровня, но один из относительно простых.

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


Игрок касается участка синей зоны, который в данный момент является внешним. После чего красная и зелёная зона перекладываются в новое положение.


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

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


В скрипте каждой плитки выставлен ID, по которому она подцепит нужный фрагмент.

Игра



Теперь подробнее про сам геймплей. На старте игрок вбирает одну из машинок и появляется в игровом мире. Машинки умеют прыгать, стрейфиться, и стрелять, если подберут специальное живое оружие, бегающее по карте. Если машинка уничтожена (от вражеских выстрелов или соприкосновения со взрывоопасными грибами), то на её месте остаётся душа (нематериальная бессмертная сущность), которая может вселиться в новую оболочку. Машинка перевозит условные грузы (зелёные ящички), которые можно подбирать или выкидывать.
Прыжок и стрейфы у машинок лимитированы следующим образом: за раз можно использовать не более двух таких действий, после чего данная возможность уйдёт не коротенькую перезарядку. Таким образом можно делать, например, стрейф плюс прыжок или двойной прыжок. У души и веретенолёта лимиты не настроены, поэтому при желании можно поломать перекладку уровня, взлетев выше триггеров.





На карте присутствуют два специальных убежища, где отображаются заглушки интерфейса купли/продажи. Два первых предмета из инвентаря можно продать, обменяв на специфическую валюту свежесть (вторая валюта, прах, пока не задействована, её просто можно увеличить нажав на самый нижний предмет). При выходе из убежища день меняется на ночь (или наоборот).


Видеонарезка с моментами геймплея демо-версии. Используемый игровой движок Godot engine 3.2.3, рендер gles3

Скачать демо для своей ОС (windows 64 .exe, linux .x86_64) можно на страничке itch.io (вес архива около 60Мб):
https://thenonsense.itch.io/biome

Подсказки по управлению показываются в игре при нажатии кнопки Enter, внутри открывающегося инвентаря.

WASD передвижение
мышь частичное прицеливание (и влияние на автокамеру)
Пробел прыжок
Левая кнопка мыши выстрел
Q,E стрейф
1 включить/выключить автокамеру
2,3 приблизить/отдалить камеру
PgUp случайный импульс

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

4 большая степень приближения камеры
Home превратиться в душу либо в базовую машинку
End создать врага
PgDown создать живое оружие

Бонус


Недавно появившийся в прототипе летающий транспорт веретенолёт.

Также есть видео из другого прототипа на Unigine engine, с более ранней версией этой биомашинки:
Подробнее..

Из песочницы Механики для реализации платформера на игровом движке GodotEngine

03.10.2020 14:23:19 | Автор: admin
Здравствуйте, я разрабатываю на Godot Engine игры примерно 2 года, поэтому решил создать сборник программного кода для реализации базовых механик платформера. Данный сборник рассчитан на новых, в использовании Godot engine, пользователей.

Базовый код


Для того чтобы персонаж подчинялся физике нужно вызвать move_and_slide() в методе _process() или _physics_process().

Во всём последующем коде я буду использовать статическую типизацию.

extends KinematicBody2D# Пока не ввели поддержку gdscript во вставках кода, придётся обходиться подсветкой pythonvar velocity: Vector2 = Vector2.ZERO # Объявляем переменную ускорения.# Нужна чтобы указывать смещение персонажа# относительно текущей позицииfunc _physics_process(_delta: float) -> void:# Ниже будет размещён программный кодself.velocity = self.move_and_slide(self.velocity, Vector2(0, -1)) # Перемещает персонажа в направлении указанном в первом аргументе.# Второй аргумент - нормаль пола, или же направление вверх.# Возвращает новое ускорение с учётом столкновений.

Данного кода будет достаточно чтобы заставить любой объект KinematicBody2D двигаться в зависимости от столкновений c другими объектами, но управлять этим объектом невозможно, тем более в платформерах этот объект по-прежнему не может падать вниз. Для того чтобы объект начал падать, нужно увеличивать значение self.velocity.y на положительное значение. Дело в том, что Godot Engine считает обе координаты из левого верхнего угла в правый нижний в режиме 2D. Для того чтобы объект падал нужно к ускорению прибавлять что-то. Обычно я использую константу GRAVITY, которая задаётся в начале программы. Далее будет представлен код с изменениями для того, чтобы объект падал.

extends KinematicBody2Dconst GRAVITY: int = 40# скорость падения в 40 пискелей будет достаточной, чтобы всё работало как надоvar velocity: Vector2 = Vector2.ZERO # Переменная ускоренияfunc _physics_process(_delta: float) -> void:# Ниже вставлять вызовы функций перемещения# Ниже можно ничего не трогатьself.velocity.y += GRAVITY# Умножать на _delta не нужно, мы же не используем self.move_and collideself.velocity = self.move_and_slide(self.velocity, Vector2(0, -1))# И да, объект сам перестанет падать на земле. Здесь не нужно усложнять.

Управление перемещением стоит реализовывать в отдельном методе, чтобы не слишком усложнять 1 метод. Тем более никто не выдержит повторение одного и того же кода в одной статье 33 раза.

Перемещение


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

  • Первый, новый для меня способ
  • func move_character() -> void:var direction: float = Input.get_action_strength("ui_right") - Input.get_action_strength("ui_left") # Для определения направления нужно определить разницу между силой нажатия# перемещения вправо(+ по x, от 0 до 1) и влево(- по x, от 0 до 1).# Если вправо = 0, то скорость < 0 и наоборот, а если зажаты влево и# вправо, то направление равно нулю.self.velocity.x = direction * MOVE_SPEED # Умножаем на скорость.# вставить const MOVE_SPEED = # Скорость персонажа в пикселях под const GRAVITY
    
  • Второй способ
  • func move_character() -> void: # Гораздо медленнееvar direction: float = 0if Input.is_action_pressed("ui_left"):direction = -1elif Input.is_action_pressed("ui_right"):direction = 1else:direction = 0self.velocity.x = direction * MOVE_SPEED# вставьте const MOVE_SPEED = #скорость персонажа в пикселях# под const GRAVITY
    

Прыжок


Прыжок реализовывается почти также легко и однозначно, как первый способ перемещения. Для этого нужно создать константу силы прыжка, как скорость перемещения в начале и поставить значение большее чем значение гравитации. Сразу перейдём к программному коду:

func jump() -> void:if self.is_on_floor(): # Проверяем, на полу ли игрок.# Можно усложнить чтобы ввести двойной, но этот выпуск рассчитан на начинающихif Input.is_action_pressed("ui_jump"): # Назначаем в настройках событие# нажатия ui_jump и назначаем в него кнопку прыжка.# или используем "ui_up"self.velocity.y -= JUMP_POWER# const JUMP_POWER... создаём как и говорил ранее константу силы прыжка около остальных

Осталось только нарисовать карту, добавить коллизии и другое, не относящееся напрямую к основному коду и за игрового персонажа можно будет наконец устроить пробежку по локациям, но об этом всё-таки в другой раз, ведь пока что другие фрагменты кода я не придумал как правильнее объяснить, да и это выходит за пределы темы сообщения.
Подробнее..

Механики для реализации платформера на Godot engine. 2 часть

03.10.2020 20:19:18 | Автор: admin
Здравствуйте, это продолжение предыдущей статьи о создании игрового персонажа в GodotEngine. Я наконец понял, как реализовать некоторые механики, такие как второй прыжок в воздухе, карабканье по, и прыжок от стены. Первая часть была более простой по насыщенности, так как с чего-то же нужно было начинать, чтобы потом доработать или переделать.

Для начала я решил собрать весь предыдущий код, чтобы те, кто использовали информацию из предыдущей статьи поняли, как я представлял себе программу полностью
extends KinematicBody2D# Константыconst GRAVITY: int = 40const MOVE_SPEED: int = 120 # Скорость перемещения персонажа в пикселяхconst JUMP_POWER: int = 80 # Скорость прыжка# Переменныеvar velocity: Vector2 = Vector2.ZEROfunc _physics_process(_delta: float) -> void:# Ниже вставлять вызовы функций перемещенияmove_character() # Перемещение персонажаjump()# Ниже можно ничего не трогатьself.velocity.y += GRAVITYself.velocity = self.move_and_slide(self.velocity, Vector2(0, -1))func move_character() -> void:var direction: float = Input.get_action_strength("ui_right") - Input.get_action_strength("ui_left") self.velocity.x = direction * MOVE_SPEEDfunc jump() -> void:if self.is_on_floor():if Input.is_action_pressed("ui_accept"): # Я вспомнил про событие ui_accept# Оно вмещает в себя нажатие прыжкаself.velocity.y -= JUMP_POWER

Надеюсь тем, кто читал предыдущую статью стало примерно понятно, как всё работает. Теперь вернёмся к разработке.

Машина состояний


Машина состояний(в моём понимании) часть программы, что определяет состояние чего либо: в воздухе, на полу, на потолке, или на стене, а также определяет что должно происходить с персонажем в том или ином месте. В GodotEngine есть такая вещь как enum, что создаёт перечисление, где каждый элемент является, заданной в коде, константой. Думаю лучше покажу это на примере:
enum States { # Создаётся перечисление States, к константам которого можно обращаться через States.IN_AIR, States.ON_FLOOR...IN_AIR, # В воздухеON_FLOOR, # На полу ON_WALL # На стене}

Данный код можно смело положить в самое начало скрипта игрового персонажа и держать в голове, что он существует. Следом инициализируем переменную в нужном месте var current_state: int = States.IN_AIR, которая равна нулю, если использовать print. Далее нужно как-то определять что игрок в текущем состоянии будет делать. Думаю многим опытным разработчикам пришедшим из C++ знакома конструкция switch () {case:}. В GDScript есть похожая адаптированная конструкция, хотя и switch также есть в планах у разработчиков. Конструкция называется match. Думаю будет правильнее показать данную конструкцию в деле, так как рассказывать будет сложнее, чем показывать:
func _physics_process(_delta: float) -> void:# Ниже функции перемещенияmatch (self.current_state):States.IN_AIR:# Вызов методов что доступны в воздухе.self.move_character()States.ON_FLOOR:# Вызов методов, что доступны на земле.self.move_character()self.jump()States.ON_WALL:# вызов методов, что доступны, если мы упремся лицом в стену. Пока кроме перемещения ничего нет.self.move_character()# Ниже будет остальной код

Но мы до сих пор не меняем состояния. Нужно создать отдельную функцию, которую будем вызывать перед match-ем, чтобы изменять переменную current_state которую стоит добавить в код к остальным переменным. А функцию назовём update_state().
func update_state() -> void:# Тут всё зависит от запланированных разработчиком возможностей персонажа.if self.is_on_floor():self.current_state = self.States.ON_FLOORelif self.is_on_wall() and !self.is_on_floor():# Когда персонаж только на стене.self.current_state = self.States.ON_WALLelif self.is_on_wall() and self.is_on_floor():# Ситуация угла. Будем в данном случае на стене.self.current_state = self.States.ON_WALLelse: # Во всех других случаях будем в воздухеself.current_state = self.states.IN_AIR

Теперь, когда машина состояний готова, мы можем добавлять уйму функций. В том числе и добавить анимации к персонажу Даже не так Мы можем добавить тонну анимаций персонажу. Система стала модульной. Но на этом мы не закончили с кодом. Я сказал в начале, что покажу, как делать дополнительный прыжок в воздухе, карабканье по, и прыжок от стены. Начнём по порядку.

Дополнительный прыжок в воздухе


Во-первых, добавьте вызов прыжка в состоянии States.IN_AIR в наш match, который мы чуток доработаем.
Вот код нашего прыжка, который я исправил:
func jump() -> void:# Старую проверку в мусор. Мы сделаем её позже.if Input.is_action_pressed("ui_accept"): # Назначаем в настройках событиеif self.current_state == self.States.ON_FLOOR:# Как раньше, но добавляем проверку через текущее_состояниеself.velocity.y -= JUMP_POWERelif (self.current_state == self.States.IN_AIR or self.current_state == self.States.ON_WALL)and self.second_jump == true:# Тут проверяем на другие состояния и можем ли мы вообще прыгнуть второй разself.velocity.y = -JUMP_POWER# Сбрасываем накопленное ускорение падения и совершаем прыжокself.second_jump = false# Не забудьте добавить var second_jump: bool = true в самый верх. и в update_state()# Добавьте после if self.is_on_floor(): self.second_jump = true # чтобы сбрасывать состояние прыжка после приземления на пол.

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

Карабканье по стенам


К нашему сожалению, Нормаль стены GodotEngine не позволяет узнать, из чего следует, что нам придётся создать небольшой костыль. Для начала я сделаю сноску имеющихся на данный момент переменных, чтобы можно было проще сказать что изменилось.
extends KinematicBody2D# Сигналыsignal timer_ended # нужно чтобы заставить работать yield в wall_jump, что основан на обмане управления.# Константыconst GRAVITY: int = 40const MOVE_SPEED: int = 120 # Скорость перемещения персонажа в пикселяхconst JUMP_POWER: int = 80 # Скорость прыжкаconst WALL_JUMP_POWER: int = 60 # Сила прыжка от стены. Нужно для соответственной функцииconst CLIMB_SPEED: int = 30 # Скорость вскарабкивания# Переменныеvar velocity: Vector2 = Vector2.ZEROvar second_jump: bool = truevar climbing: bool = false # Нужно чтобы определять, карабкается ли игрок по стене, или нет.var timer_working: bool = falsevar is_wall_jump: bool = false # Нужно, чтобы определить, а от стены ли мы прыгаемvar left_pressed: bool = false # Для искусственного зажатия кнопки влевоvar right_pressed: bool = false # Для искусственного зажатия кнопки вправоvar current_state: int = States.IN_AIRvar timer: float = 0 # счётчик таймера, что будет встроен в _process(delta: float)var walls = [false, false, false] # определения стен и потолка. Нулевой и второй - стены. Первый - потолок.# Пока нужны только нулевой и второй# Перечисленияenum States {IN_AIR, # В воздухеON_FLOOR, # На полу ON_WALL # На стене}# И я сделаю чуть больше чем сказал, добавив метод _process() с самодельным таймеромfunc _process(delta: float):if timer_working:timer -= deltaif timer <= 0:emit_signal("timer_ended")timer = 0

Теперь нужно определять по какой стене игрок карабкается.
Вот дерево сцены, что вам стоит подготовить для реализации определителя стороны стены
image
Разместите 2 Area2D по бокам персонажа и CollisionShape2D обоих не должны пересекаться с персонажем. Подпишите соответственно объекты WallLeft/WallRight и присоедините сигналы _on_body_endered и _on_body_exited к единственному скрипту персонажа. Вот код который нужен чтобы определять стены(Добавить в самый конец скрипта):
# Надеюсь тут всё интуитивно понятно# Если нет, то комментарии вам в помощьfunc _on_WallRight_body_entered(_body):if (_body.name != self.name):self.walls[0] = true # Если засечённый объект не мы, объект слева - стенаfunc _on_WallRight_body_exited(_body):self.walls[0] = false # Когда тело вышло из коллизии другого объекта - стены слева нетfunc _on_WallLeft_body_entered(_body):if (_body.name != self.name):self.walls[2] = true # Если засечённый объект не мы, объект справа - стенаfunc _on_WallLeft_body_exited(_body):self.walls[2] = false # Когда тело вышло из коллизии другого объекта - стены справа нет

Приступим к методу карабканья. В коде всё будет сказано за меня
func climbing() -> void:if (self.walls[0] or self.walls[2]): # Если стена слева или стена справа есть# Создайте новый action в настройках и назовите ui_climb. Об этом я уже говорил в первой части.self.climbing = Input.is_action_pressed("ui_climb")else:self.climbing = false

И нужно переписать управление move_character() для того, чтобы можно было не просто держаться, а карабкаться вверх вниз, благо у нас есть direction
func move_character() -> void:var direction: float = Input.get_action_strength("ui_right") - Input.get_action_strength("ui_left") if !self.climbing:self.velocity.x = direction * MOVE_SPEEDelse:self.velocity.y = direction * CLIMB_SPEED

И исправляем наш _physics_process()
func _physics_process(_delta: float) -> void:# Ниже функции перемещенияmatch (self.current_state):States.IN_AIR:self.move_character()States.ON_FLOOR:self.move_character()self.jump()States.ON_WALL:self.move_character()# Ниже можно ничего не трогатьif !self.climbing:self.velocity.y += GRAVITYself.velocity = self.move_and_slide(self.velocity, Vector2(0, -1))

Теперь персонаж должен уметь карабкаться по стенам.

Прыжок от стены


Теперь реализуем прыжок от стены.
func wall_jump() -> void:if Input.is_action_just_pressed("ui_accept") and Input.is_action_pressed("ui_climb"): # Если нажата 1 раз кнопка прыжка и зажата кнопка карабканьяself.is_wall_jump = true # Мы прыгаем от стены = даself.velocity.y = -JUMP_POWER # Изменяем ускорение до -JUMP_POWERif walls[0]: # Если стена слеваself.timer = 0.5 # Установить self.timer на 0.5 секундыself.timer_enabled = true # Включаем таймерself.left_pressed = true # ставим переменную left_pressed на даyield(self, "timer_ended") # Дожидаемся срабатывания сигнала timer_endedself.left_pressed = false # отпускаем left_pressedif walls[2]: # Если стена справаself.timer = 0.5 # Установить self.timer на 0.5 секундыself.timer_enabled = true # Включаем таймерself.right_pressed = true # ставим переменную right_pressed на даyield(self, "timer_ended") # Дожидаемся срабатывания сигнала timer_endedself.right_pressed = false # отпускаем right_pressedself.is_wall_jump = false # Прыгнули. Больше не на стене

Добавляем вызов этого метода в наш match -> States.ON_WALL и мы присоединили наш метод к остальной части _physics_process().

Заключение


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

Механики для реализации платформера на Godot engine. 3 часть

15.10.2020 22:20:39 | Автор: admin
Здравствуйте, это уже 3-я часть сборника механик для реализации платформера. На этот раз мы поговорим о жизни, смерти и сохранении с последующей загрузкой. Это будет не совсем урок по реализации платформера, но без данной части программы мы особо и не сможем нормально играть. Только бесконечные уровни а-ля игровые автоматы начала девяностых.

Предыдущие статьи:



Пожалуй начнём в обратном порядке: сначала система сохранения и загрузки, потом о смерти и жизни.

Система сохранения и загрузки


Вообще по умолчанию Godot Engine создаёт папку для данных пользователя где-то в AppData/godot/app_userdata/[название проекта], если не включить галочку в use custom user dir.



Вот как это выглядит в проекте.

После этих действий, проект будет создавать папку в appdata/[название проекта]. Также user:// будет указывать туда. Теперь мы точно знаем куда будут отправляться любые файлы проекта. Теперь стоит создать в проекте папку scripts куда мы будем ложить все скрипты для синглтонов. О создании синглтонов рассказано в документации, так что рассказывать ещё и это будет лишним.
Ладно. Приступим к коду.

# Код снова будет настроен на python, так как он больше всего похож на gdscript, а последний пока не ввели.# scripts/lvl_mgr.gd допустим в этом файле будет храниться информация для переходов между уровнямиextends Node;var current_level: int = 0 # Для выбора уровня если у вас полностью линейный платформер с нумерацией уровней.var current_scene: Node = null # Фактически - указатель на текущую сцену. Для редактора - переменная равная какому-то узлуfunc load_level(lvl_id: int = 0) -> void:var lvl_name = "res://levels/level%s/level%s.tscn" % [lvl_id, lvl_id]# Создаём переменную чтобы хранить путь к уровнюself.current_level = lvl_id # запоминаем текущий уровень для того, чтобы перезагружаться отсюдаgoto_scene(lvl_name) # Вызываем метод перехода к сценеfunc goto_scene(path: String) -> void:call_deferred("_deferred_goto_scene", path)func _deferred_goto_scene(path: String) -> void:# Здесь всё и будет происходить.current_scene.free() # Очищаем переменнуюvar scene = ResourceLoader.load(path) # Загружаем нужную сценуcurrent_scene = scene.instance() # Создаём из упакованной сцены узелget_tree().get_root().add_child(current_scene) # Добавляем к корню дерева сцен наше дерево из сценыget_tree().set_current_scene(current_scene) # Опционально. Делает совместимым с SceneTree.change_scene() API. Бла-бла-бла. Если коротко, то если это добавить мы сможем держать N деревьев из сцен и переключаться между ними.

Это позволит нам переключать уровни. Далее в коде данный файл будет упоминаться как синглтон LevelManager. На кой нам это нужно? Это позволяет запомнить игре, какой уровень перезапускать после смерти игрока и с чего продолжить, если игрок загружает игру.

Итак, приготовления более-менее закончены, а теперь приступим к самой системе сохранений-загрузок.

# scripts/save_mgr.gd Подключить как синглтон SaveManagerextends Nodevar file: File = File.new() # Создаём обработчик файловvar dir: Directory = Directory.new()var save_data = { # Данные для сохранения. Чтобы не формировать их каждый раз"current_level": LevelManager.current_level}func _ready():if !dir.dir_exists("user://saves"):dir.make_dir("user://saves")func update_save_data() -> void: # Для обновления данныхself.save_data["current_level"] = LevelManager.current_levelfunc upload_save_data() -> void: # Для загрузки данных во все фрагменты программы.LevelManager.current_level = self.save_data["current_level"]func save_progess(file_name: String) -> void:file.open("user://" + file_name, File.WRITE)# Теперь мы как-то должны упаковать данные что сохраняем.# Пусть это будет словарь.self.update_save_data() # Собирает по игре данные в save_data.file.store_string(str(self.save_data)) # Сохраняет данные из save_data в форме строкиfile.close()returnfunc load_progress(file_name: String) -> void: # Нужно тоже получить имя файла сохранения, если их в игре > 1file.open("user://" + file_name, File.READ)# Также открыли файл.self.save_data = JSON.parse(file.get_as_text()).result # Вытаскивает в self.save_data результат парсинга содержимого файла.self.upload_save_data() # Вызов этого метода вытаскивает из save_data все данные по правильным местамfile.close()return

На этом с сохранениями и загрузками покончено. На самом деле это можно улучшать и улучшать, но пока хватает этого.

Смерть


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

res://+-----+- scenes|+-----+ dead_screen||+---- dead_screen.tscn||+---- dead_screen.gd|+ ...+ ...

Теперь откройте нашего персонажа и создайте новый метод dead().

# . . .func dead() -> void:LevelManager.goto_scene("res://scenes/dead_screen/dead_screen.tscn")return# . . .

В сцене dead_screen.tscn примерно следующее создайте:


И вот скрипт:

extends Controlfunc _on_exit_pressed(): # Выход в главное менюLevelManager.goto_scene("res://scenes/main_menu/main_menu.tscn") # Такая сцена должна существоватьfunc _on_restart_pressed():LevelManager.load_level(LevelManager.current_level) # Если нажать restart_level - загрузится текущий уровень

Ну а живем мы пока не мертвы

Заключение


Теперь вы поняли как я насылаю смерть на игрока, если она не должна быть по сюжету. Ну и ещё как работает в моём понимании переключение сцен и система сохранений. На данный момент мне пока нечего рассказать, поэтому я иду думать как создать нейронную сеть, что будет двигаться к точке N. Об этом проекте расскажу больше когда завершу его. Но это совсем другая история.

P. s. И да, отпишитесь в комментариях о том, о чём мне стоит рассказать в следующий раз. Постараюсь подготовить материал достаточно качественно.
Подробнее..

Модули и расширения для Godot 3, ссылки и краткий обзор существующих

24.10.2020 06:20:38 | Автор: admin

Различие модуля и расширения:

Расширение (plugin/addon) - работает в официальном редакторе Godot и не требует никаких дополнительных действий, и работает со стандартными шаблонами экспорта.

Модуль (module) - требует модификации исходного кода и сборки всего редактора Godot, также сборки каждого из шаблонов для экспорта на каждую из платформ.

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

Установка модулей и расширений:

Расширения (plugin/addon) - для установки скопируйте расширение в каталог addons в вашем проекте. Структура должны быть такой - addons/<название расширения>/, достаточно скопировать <название расширения> в каталог addons. И включить в опциях проекта Проект (Project) - Настройки проекта (Project Settings)

Модули (module) - в большинстве случаев достаточно скопировать каталог с модулем в godot_<версия исходный код>/modules/<название модуля> и сделать сборку Godot редактора и нужных шаблонов следуя инструкции. После сборки функционал модуля станет доступным.

Расширения:

Godot Console - позволяет создавать простой UI элемент Консоль, и просто добавлять туда команды, полезно во многих случаях.

Godot-3D-text-plugin - позволяет прямо в редакторе создавать трехмерный объект(mesh) в форме текста, создавать буквально двумя кликами, есть поддержка шрифтов. Для получения объекта нажать Convert to MeshInstance.

smoothing-addon - позволяет просто создавать плавную интерполяцию положения трехмерных объектов, больше информации по ссылке на расширение. Видео использования, физический ФПС установлен на 8, слева реальная позиция, справа используя smoothing-addon:

3D Trail addon for Godot - оставляет плоский след за объектами, к сожалению геометрия следа генерируется на CPU, что даст сильную нагрузку при использовании на десятках объектах одновременно. Лучше всего использовать не более чем на нескольких объектах.

Concept Graph - я не тестировал и не пользовался, для ознакомления рекомендую посмотреть видео в этом твите автора, позволяет создавать сложные правила для трехмерных объектов, что дает комплексный результат. Похоже на визуальное программирование, код писать не надо.

Scatter - для его работы требуется другой плагин Polygon Path, позволяет делать дубликаты одного объекта в выделенной области, например трава или деревья.

Godot-Planar-Reflection-Plugin - создание отражений с помощью камеры. Может пригодиться для реалистичных отражений. Можно также использовать для отражений в воде Больше информации и примеров по ссылке.

Дальше не совсем плагины, просто готовые полезные шейдеры, скрипты, и примеры:

Godot Realistic Water - хороший и быстрый шейдер воды с выделением (используя depth) объектов пересекающих воду.

Godot_force_shield_shader - простой эффект энергетического щита, главное то что реализована логика выделения пересекающих объектов используя depth.

Screen-Space-Decals - логика отпечатков на шейдерах, так как в Godot 3 нет поддержки decals. Более комплексное использование этой техники в Godot я описал в своей статье на хабре ссылка, где есть ссылки на исходный код моего измененного шейдера.

Simple Wind Shader - пример использования и создания шейдера анимации движения травы и листвы под действием ветра.

Mirror example - проект с примером создания зеркала

Camera Control Script - очень полезный скрипт для контроля камеры, по сути шаблон для всех трехмерных камер.

Модули:

Futari-addon - очень полезный модуль для GPU-частиц, позволяет добавлять физический коллайдер сферу или плоскость, и частицы будут взаимодействовать с ними, также можно добавлять области с ускорением на подобии ветра. Эта логика работает и в WebGL2. На хабре есть статья с моим примером использования этого модуля. Есть демо проект от автора.

HeightMap terrain plugin - создание площадей как карты высот, есть поддержка разных текстур, травы, также LOD и физика. Полноценный HeightMap с которым можно делать все что требуется.

Godot-lportal - добавляет поддержку occlusion culling в Godot 3. Я лично не нашел применения этому плагину, по утверждению автора этот плагин позволяет делать даже порталы в Godot 3 без потери производительности. Также, возможно, этот плагин будет добавлен в Godot 3 в будущем.

Godot-lsimd - добавляет поддержку SIMD в Godot 3. Использовать можно прямо в GDScript, ускоряя работу с массивами и большими объемами данных.

Подробнее..

Механики ловушек и интерактивных объектов в Godot Engine. Часть 2

27.10.2020 16:17:12 | Автор: admin
Приветствую вас во второй части механик ловушек и интерактивных объектов в Godot Engine. Я решил пока-что выпустить эту часть, чтобы выпустить эту часть, чтобы показать механики, которые отвечают за направление уровня в играх. Я имею в виду двери, ключи и движущиеся платформы. В данном случае движущиеся по желанию игрока. Такие платформы можно будет ронять, использовать как мост и другое.Для того чтобы всё работало мы будем использовать NodePath. Только в этом случае не для перемещения игрока, а для перемещения объектов относительно текущего положения.Предыдущие статьи:

Переключатель

Для начала создадим переключатель. Это будет Area2D со спрайтом и коллизией. Он будет иметь метод interact(_interactor: Node), для того чтобы взаимодействовать с другими объектами, на которые будет указывать NodePath. Вот скрипт этого переключателя:
toolextends Area2Dexport (NodePath) var dependent_node: NodePathexport (bool) var toggle: bool = falsevar prev_toggle: bool = falsefunc _process(_delta: float) -> void:if prev_toggle != toggle: # Отвечает за воспроизведение нужной анимации. Нуждается в доработкеif toggle == true:$AnimatedSprite.play("toggle_on")else:$AnimatedSprite.play("toggle_off")prev_toggle = togglefunc interact(_interactor: Node) -> void:if !$AnimatedSprite.is_playing(): # Если Анимация воспроизводится и есть метод "toggle" у dependent_bode if get_node(dependent_node).has_method("toggle"):self.toggle = !self.toggle # поменять значение toggleget_node(dependent_node).toggle(toggle) # Вызвать метод toggle у dependent_node и передать toggle
А так выглядит сцена переключателя

Улучшение шипов

Теперь вспомните те шипы. Теперь мы улучшим их, чтобы добавить интерактива в игру. Пусть они будут переключаться также рычагом.
# Добавьте в конец того скрипта шиповfunc toggle(toggled: bool) -> void:self.showed = toggled
Теперь скрипт будет выглядеть следующим образом:
toolextends StaticBody2Dexport var showed = falseexport (bool) var timer = falsevar prev_showed = showedfunc _physics_process(_delta):if timer and $Timer.is_stopped():$Timer.start()if showed != prev_showed:if showed:$AnimatedSprite.play("show")else:$AnimatedSprite.play("hide")prev_showed = showedfunc _on_Area2D_body_entered(body):if body.name.ends_with("Actor") and showed:body.dead()func _on_Timer_timeout():if timer:showed = !showed$Timer.start()func toggle(toggled: bool) -> void:self.showed = toggled
Как-то так. Теперь игрок может менять состояние шипов.

Ключи и двери

Ключи

В скрипте игрока было число ключей и правила их добавления. Теперь нужно показать как они выглядят и дать их рецепт. Ключи будут подходить ко всем дверям, но будут ломаться после открытия.
extends Area2D # Как всегда спрайт и коллизия среди детей корневого узла сценыfunc _on_Key_body_entered(body):if body.name == "Actor": # Если подобравший ключ - Actorbody.key_picked_up() # Вызови метод подбора ключаself.queue_free() # Удалить с поля ключ
Теперь если войти в ключ он автоматически подберется. И держите скриншот того, как выглядит мой ключ.

Двери

По-началу двери могли только открываться, но это было в начале. Теперь я придумал как позволить им закрываться.
extends StaticBody2Dexport (bool) var key_can_used: bool = truefunc open():if !key_can_used: returnself.visible = falsefunc close():if !key_can_used: returnself.visible = truefunc toggle(vis: bool):self.visible = vis
Я добавил возможность переключить состояние двери через рычаг. Вот как выглядит сцена в редакторе.

Заключение

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

Кроссплатформенный мультиплеер на Godot без боли

30.01.2021 14:20:43 | Автор: admin

Что хотим сделать?

Синхронизацию действий игроков в игре с клиент-серверной архитектурой. Должна быть возможность играть из браузера.

Для примера реализуем простую чат-комнату:

  1. При соединении:

    1. Клиент получает уникальный ID;

    2. Клиент получает информацию о всех остальных игроках (ID + имя);

    3. Все остальные игроки получают информацию о новом игроке (ID + имя по умолчанию);

    4. В консоли появляется сообщение о входе.

  2. При потере соединения:

    1. Все остальные игроки получают информацию о выходе игрока с сервера (ID);

    2. В консоли появляется сообщение о выходе.

  3. При изменении имени:

    1. Если имя уже занято - игрок получает ошибку;

    2. Все игроки уведомляются об изменении имени;

    3. В консоли появляется сообщение.

  4. При отправке сообщения в чат:

    1. Все игроки видят сообщение в логе/консоли.

Примечание: ничего не мешает реализовать более сложный нетворкинг (например, передвижения игроков, какие-то другие действия) - но это выходит за рамки этой статьи и само по себе является достаточно сложной темой. Чат - это самый простой пример для демонстрации того, что такой подход для передачи данных, в принципе, работает - и цель моей статьи как раз в этом.

Что получилось?

Готовый проект можно изучить здесь: https://github.com/ktori/godobuf-over-websocket-demo

Скриншоты можно посмотреть в конце статьи.

Что будем использовать?

  • Godot - free and open source кроссплатформенный игровой движок;

  • Protobuf - механизм для эффективной сериализации/десериализации данных;

  • Godobuf - плагин для Godot, позволяющий генерировать .gd (GDScript) файлы из .proto;

  • Ktor - фреймворк для создания асинхронных сервисов Kotlin (в этой статье я буду использовать Kotlin - но бэкэнд может быть написан на любом другом языке, главное - иметь в фреймворке возможность принимать вебсокет-соединения и желательно - генератор кода из Protobuf, эти генераторы существуют для множества языков).

Плюсы этого подхода

  • Все сообщения, которыми обмениваются клиент и сервер, описываются в одном месте:

    • Из этих файлов можно сразу сгенерировать код и для сервера и для клиента;

    • В них же можно вести документацию, оставляя комментарии;

    • Описание протокола можно легко хранить в любой VCS, т.к. по сути это просто текстовые файлы;

    • Можно точно знать что обе стороны будут сериализовывать и десериализовывать сообщения одинаково - генерация кода обеспечит отсутствие забытых полей и каких-либо других ошибок свойственных при ручном чтении/записи.

  • Protobuf - бинарный формат, и в отличие от, например, JSON - будет использоваться меньший объем трафика для передачи одного и того же объема данных;

  • Protobuf позволяет добавлять новые поля, не ломая совместимость со старыми клиентами.

Минусы этого подхода

Совсем явных минусов я назвать не могу - но:

  • Сериализация/десериализация в protobuf будет проходить медленнее, чем, например, прямая запись в буфер в собственном формате;

  • Код, который генерируется из protobuf часто получается довольно громоздким и, соответственно, имеет определенную стоимость в рантайме.

Описание протокола

Готовый протофайл можно посмотреть здесь: game.proto

Создадим пустой .proto-файл, например - game.proto. В этом файле нужно описывать все сообщения, которыми будут обмениваться сервер и клиент (если сообщений будет много - можно выносить их в отдельные файлы и импортировать из основного).

В этот файл следует сразу прописать опции для парсера и кодогенератора:

syntax = "proto3";// Название пакетаoption java_package = "me.ktori.game.proto";// Название класса в котором будут находиться подклассы сообщенийoption java_outer_classname = "GameProto";

А теперь определимся, какие сообщения нам вообще нужны:

Сообщения клиент-сервер

Это сообщения, которые клиент отправляет серверу - часто они будут по сути RPC вызовами с ответом в сообщении Cl**Result от сервера. Здесь был бы очень кстати gRPC - возможно в будущем с помощью godobuf можно будет делать и gRPC-сервисы. Но пока:

//// Сообщения клиент-сервер//// Запрос на изменение имениmessage ClSetName {  string name = 1;}// Отправка сообщения в чатmessage ClSendChatMessage {  string text = 1;}// Объединение всех сообщений, отсылаемых клиентомmessage ClMessage {  // Только одно из этих полей может быть заполнено, таким образом сервер  // может быстро определить, что именно хочет сделать клиент  oneof data {    ClSetName set_name = 1;    ClSendChatMessage send_chat_message = 2;  }}

Сообщения сервер-клиент

//// Сообщения сервер-клиент//// Результат выполнения команды ClSetNamemessage ClSetNameResult {  // Удалось ли изменить имя - имя нельзя изменить на уже занятое  bool success = 1;}// Отсылается сервером - объединение всех возможных результатов выполнения команды от клиентаmessage ClMessageResult {  oneof result {    ClSetNameResult set_name = 1;  }}// Отсылается клиенту один раз при соединении// Получатель этого сообщения сохраняет у себя полученный ID и выданное сервером имяmessage SvConnected {  int32 id = 1;  string name = 2;}// Уведомление о подключении нового клиента// Получатель должен сохранить имя клиента по IDmessage SvClientConnected {  int32 id = 1;  string name = 2;}// Уведомление об отключении клиента// Получатель может удалить у себя информацию о клиенте по IDmessage SvClientDisconnected {  int32 id = 1;}// Уведомление об изменении имени// Получатель должен изменить имя клиента по ID на новоеmessage SvNameChanged {  int32 id = 1;  string name = 2;}// Сообщение в чатеmessage SvChatMessage {  int32 from = 1;  string text = 2;}// Объединение всех сообщений которые сервер посылает клиентуmessage SvMessage {  // Только одно из этих полей будет заполнено в одном SvMessage  oneof data {    ClMessageResult result = 1;    SvConnected connected = 2;    SvClientConnected client_connected = 3;    SvClientDisconnected client_disconnected = 4;    SvNameChanged name_changed = 5;    SvChatMessage chat_message = 6;  }}

Таким образом получаем следующую структуру:

  • Все возможные сообщения от клиента обернуты в ClMessage;

  • Все возможные сообщения от сервера обернуты в SvMessage;

    • Ответы на вызовы клиента обернуты в поле result - сообщение ClMessageResult.

Лично для себя я определилась с такой naming convention:

  • ClFooBar для сообщений, которые шлёт клиент серверу;

  • SvFooBar для сообщений, которые шлёт сервер клиенту, за исключением:

  • ClFooBarResult для передачи результата обработки ClFooBar.

Создание клиентской части на Godot

Для начала нужно создать проект и основную сцену (обычную пустую 2D сцену).

Добавление плагина Godobuf

Плагин можно скачать здесь: https://github.com/oniksan/godobuf, инструкция по установке есть в README репозитория - нужно распаковать себе в проект папку addons.

Проект после установки аддона godobuf Проект после установки аддона godobuf

Открытие соединения

Для соединения с сервером используется класс WebSocketClient (документация по WebSocketClient). Работать с ним просто: устанавливаем обработчики событий, а затем указываем URL сервера для соединения.

Создадим скрипт, который будет открывать соединение на корневой ноде сцены - там же будут заготовки для функций обработки событий от вебсокета:

extends Node2Dvar ws: WebSocketClient# Вызывается при загрузке сценыfunc _ready():    # Создаем WebSocketClient и подключаем обработчики событий    ws = WebSocketClient.new()    ws.connect("connection_established", self, "_on_ws_connection_established")    ws.connect("data_received", self, "_on_ws_data_received")    # Подключаемся к локалхосту по порту 8080    ws.connect_to_url("ws://127.0.0.1:8080")# Будет вызываться при установке соединенияfunc _on_ws_connection_established(_protocol):    pass# Будет вызываться при получении сообщений из вебсокетаfunc _on_ws_data_received():    pass

Генерация биндингов protobuf:GDScript

Здесь всё очень просто! Во вкладке Godobuf указываем путь до нашего proto-файла и путь куда будет сохранен получившийся скрипт:

Окно GodobufОкно Godobuf

Если в прото-файле нет ошибок, то мы увидим сообщение об успешной компиляции и в папке проекта появится нужный скрипт.

Отправка сообщений

Настройка сцены

 Сцена Сцена

В своей сцене я сделала отдельный контейнер для сообщений и два поля - для ввода текста и имени. Сигналы pressed от кнопок Send и Rename я подключила в скрипт на корневой ноде. Также для вывода сообщений на сцену я сделала функцию show_message, она просто добавляет новый объект Label с текстом сообщения в VBoxContainer, который располагает объекты вертикально.

Отправка запросов на сервер

После создания этих полей ввода и кнопок нужно сделать так чтобы они что-то делали.

Сперва загрузим получившиеся биндинги в наш скрипт:

const GameProto = preload("res://game_proto.gd") 

Теперь можно добавить код создания ClMessage при нажатии на кнопки Send/Rename:

# Изменяем имя на введенное в $Namefunc _on_SetName_pressed():    var msg = GameProto.ClMessage.new()    var sn = msg.new_set_name()    sn.set_name(name_input.text)    send_msg(msg)# Отправляем сообщение из $Message и очищаем полеfunc _on_SendMessage_pressed():    var msg = GameProto.ClMessage.new()    var scm = msg.new_send_chat_message()    scm.set_text(message_input.text)    message_input.clear()    send_msg(msg)

Самое интересное - сама отправка сообщения по вебсокету происходит в функции send_msg. Вот она:

# Отправляет ClMessage на серверfunc send_msg(msg: GameProto.ClMessage):    # Конвертируем ClMessage в PoolByteArray и отправляем его по соединению ws    ws.get_peer(1).put_packet(msg.to_bytes())

Функция to_bytes (как и весь класс ClMessage) сгенерированы плагином godobuf - и никаких операций с буферами руками нам делать не надо!

Обработка сообщений

Теперь наш клиент может отправлять сообщения - но он ещё не способен их принимать. Сейчас мы это исправим, добавив обработку входящих сообщений - этот блок кода будет объемнее, но по большей части код там повторяется.

Код получения и обработки сообщений
# Вызывается часто по интервалуfunc _process(_delta):    # Производит чтение из вебсокета, читает входящие сообщения    ws.poll()# Будет вызываться при установке соединенияfunc _on_ws_connection_established(_protocol):    show_message("Connection established!")# Будет вызываться при получении сообщений из вебсокетаfunc _on_ws_data_received():    # Обработка каждого пакета в очереди    for i in range(ws.get_peer(1).get_available_packet_count()):        # Сырые данные из пакета        var bytes = ws.get_peer(1).get_packet()        var sv_msg = GameProto.SvMessage.new()        # Превращение массива байтов в структурированное сообщение        sv_msg.from_bytes(bytes)        # Обрабатываем уже сконвертированное сообщение        _on_proto_msg_received(sv_msg)# Будет вызываться после чтения и конвертации сообщения из вебсокетаfunc _on_proto_msg_received(msg: GameProto.SvMessage):    # т.к. все эти поля находятся в блоке oneof - заполнено может быть только    # одно из них    if msg.has_connected():        pass    elif msg.has_client_connected():        pass    elif msg.has_client_disconnected():        pass    elif msg.has_chat_message():        pass    elif msg.has_name_changed():        pass    elif msg.has_result():        pass    else:        push_warning("Received unknown message: %s" % msg.to_string())

Важно периодически вызывать poll на WebSocketClient, иначе сигналы о входящих сообщениях никогда не придут. В данном случае это происходит в _process

После этого остается только заполнить логику обработки конкретных сообщений - но сначала добавим хранилище известных клиенту имён и переменную для ID текущего клиента:

# Хранит ID этого клиентаvar own_id: int# Хранит пары ID <> Имяvar names = Dictionary()

И обработку одного из возможных сообщений с сервера:

# Внутри _on_proto_msg_received  if msg.has_connected():var c = msg.get_connected()own_id = c.get_id()name_input.text = c.get_name()show_message("Welcome! Your ID is %d and your assigned name is '%s'." % [c.get_id(), c.get_name()])

Остальные блоки в этом if/elif примерно одинаковы. Получившийся код для каждого отдельного сообщения можно посмотреть на GitHub: Main.gd

Серверная часть

Серверная часть очень подробно разбираться не будет. Её можно написать на любом языке - и в данном случае это будет Kotlin с фреймворком Ktor. Напоминаю, что весь код этого проекта доступен на GitHub - сервер там достаточно простой. Но в двух словах выделю основные моменты моей

сервера:

Структура проекта

Основной gradle-проект состоит из двух модулей:

  • server - сам сервер;

  • proto - прото-файлы и сгенерированные из них биндинги:

    • Стоит обратить внимание на плагин com.google.protobuf, зависимость com.google.protobuf:protobuf-java и их конфигурацию;

    • В процессе сборки этого модуля генерируются классы, позволяющие сериализовывать/десериализовывать сообщения описанные в прото-файле.

Сам сервер работает по простому алгоритму - хранит открытые соединения, broadcast-канал для уведомлений и сообщений, принимает сообщения от клиента пока это возможно и отвечает на его запросы.

Результаты

Получившийся Godot-проект может работать как из браузера, так и с нативных сборок под Linux/Windows/Android и т.д. - всё взаимодействие клиента с сервером описывается в одном месте и в протокол легко вносить изменения.

Скриншоты

Нативный клиентНативный клиентWebSocket-клиентWebSocket-клиент

Заключение

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

  • Обработку ошибок (например, передавать отдельное сообщение error в ClMessageResult);

  • Обработку потери/восстановления соединения;

  • Многое другое.

Я надеюсь эта статья оказалась полезной и помогла разобраться в Godot, вебсокетах и protobuf.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru