• Вы когда-нибудь бывали на боевом задании?

  • Что вы имеете ввиду?

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

Godot — игровой движок, который имеет нативную поддержку dotnet. К сожалению, эта поддержка до такой степени заточена под C#, что F# она выходит боком. Почти все проблемы разрешимы, но при недостатке опыта они скатываются в большой пластилиново-волосатый валик у самого входа в подземелье, который иногда приводит к преждевременной и бессмысленной гибели. Чтобы избежать этого в данной статье я дам программу-минимум, которая позволит выжить в Godot, но не выжать из него максимум. Это не значит, что у сочетания F# + Godot нет своих плюшек. Просто мне хотелось съесть вначале сосредоточить всех мух в одном месте, а котлетами заняться потом и в более свободной манере. Также я предполагаю, что на данную статью будут натыкаться как новички в F#, так и новички в Godot, поэтому местами я буду дублировать базовые руководства.

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

Мне нравится иногда моделировать механики из настольных игр, так как это даёт свои плоды при описании бизнес-логики. Если речь идёт о каком-нибудь филлере, то для управления состоянием игры хватает встроенного в F# REPL. В более сложных сценариях пригождается UI в виде Avalonia / WPF. Я шапочно знаком с Unity и сильно больше с Monogame, но при их использовании никогда не ощущал в себе сил доделать пет-проект до конца. От Godot я ожидал чего-то аналогичного, но оказалось, что этот движок обладает необходимым мне набором готовых компонентов с очень высокой долей проницаемости. Да, есть проблемы с интеграцией F#, но это около-константные величины, которые всё меньше и меньше волнуют меня с ростом кодовой базы. В итоге Godot оказался по сложности где-то между REPL и Avalonia, а по выразительности правее любого UI-фреймворка.

Поддержка .NET в Godot

Ранее Godot использовал Mono, которого я сторонюсь больше, чем UI-проектов под WinXP. С версии 4.0 Godot переехал на полноценный .NET 6+, но названия некоторых инструментов по инерции продолжают говорить о Mono. В .NET Godot встраивается в виде SDK, который можно скачать с сайта nuget-а целиком или по пакетам. Godot.NET.Sdk заменяет дефолтный Microsoft.NET.Sdk, после чего вы получаете доступ ко всем функциям Godot. Механизм напоминает подключение WPF в самых ранних его версиях (ещё для .NET Core). Однако в отличие от WPF, вы не сможете написать Godot-приложение, используя только dotnet-проект.

В Godot есть свой язык программирования GDScript, и он считается основным, в то время как dotnet (и C#, в частности) поставляется лишь как опция-плагин. Я пока не встретил каких-то существенных различий между API для GDScript и для dotnet, и не вижу идеологических предпосылок к их появлению. У нас тот же доступ, что и у родного языка, однако и там, и там мы не в полной мере контролируем внешнюю среду исполнения. Мы вольны бегать по файловой системе, использовать Hopac, Garnet, Hedgehog и всё, что душе угодно, пока это позволяет ось. Однако движок не даст себя запустить через условный App.Run(), что не является проблемой для конечного продукта, но у нас очередной раз отобрали бесшовный REPL. Формально мы можем подключить все необходимые пакеты в интерактиве, но большинство обращений к Godot-объектами будут заканчиваться ошибками доступа и прочим nativeptr.

В 4.0 версии в связи с переездом временно пропала возможность собирать .NET-приложения под мобильные устройства, но с выходом 4.2 она вернулась с экспериментальной звёздочкой. Это не было банальной перестраховкой, так как я уже напоролся на ряд сетевых проблем на Android, которые вынудили полностью переехать на Godot.HttpClient. Разработчики обещали исправить это позднее, но и текущего workaround-а хватает. Веб-версии приложений нам недоступны, и со слов разработчиков движка, этот момент вряд ли изменится, пока в недрах dotnet не научатся заводить WASM в качестве неосновного модуля.

Развёртывание проекта и настройка редактора

Godot доступен здесь. По умолчанию он не включает поддержку dotnet и её вроде как нельзя догрузить потом, поэтому лучше сразу скачать версию Godot Engine - .NET. Установщика у Godot нет, скачанные 150 мб можно запустить непосредственно из папки. На всех своих компах я располагаю движок и репозитории godot-проектов одинаковым относительно друг друга способом (причины даны ниже).

Запустив редактор, вы увидите стандартную панель с ранее открывавшимися проектами и т. п. В отличие от VS Godot не создаёт отдельную папку под новый проект, так что это лучше сделать самостоятельно до того, как он загадит общую директорию. Встроенная поддержка gitignore ничего не знает о специфике dotnet или Visual Studio, так что от её галочки проще отказаться, взять стандартный dotnet new gitignore и дополнить его строкой .godot/*.

После открытия проекта можно будет подправить общие настройки редактора. Например, имеет смысл сразу переключить редактирование C#-скриптов на Visual Studio / Rider выставив опцию в Редактор -> Настройки редактора -> Dotnet -> Редактор -> External Editor в нужное состояние. На самом деле в половине случаев Godot продолжит использовать внутренний редактор, так что проблема параллельного редактирования исходников с вопросами про Сохранить VS Перезагрузить останется. Если вы планируете собирать проект под Android, то в тех же настройках в Экспорт -> Android можно подружить Godot с уже установленными SDK и "debug.keystore". Например, так они выглядят, если на компе уже стоит Visual Studio с расширениями под мобильную разработку:

export/android/android_sdk_path="C:/Program Files (x86)/Android/android-sdk"
export/android/debug_keystore="C:/Users/<UserName>/AppData/Local/Xamarin/Mono for Android/debug.keystore"

В Godot есть собственный формат для описания проектов (project.godot), ресурсов (.tres), сцен (.tscn), настроек самого редактора и, наверное, чего-то ещё, о чём я пока не знаю. Этот формат ориентирован на человека, и при необходимости данные файлы могут быть подправлены руками. Мне он нравится тем, что его можно осмысленно читать в дифах к коммитам. Однако, если я правильно понял, Godot не даёт готового API для работы с этим форматом. Внятных публичных пакетов я также не нашёл.

Условно основным файлом в проекте является project.godot, в нём преимущественно хранятся глобальные настройки проекта, типа разрешения экрана, основной сцены, настроек dotnet и т. п. Списка используемых файлов в нём нет, подразумевается, что всё, что лежит в папке с проектом, принадлежит ему. Godot добавит .csproj только после того, как будет добавлен хотя бы один .cs-файл или прямо дана команда Проект -> Инструменты -> C# -> Create C# Solution. .csproj и .sln файлы окажутся в корневой папке, рядом с project.godot. Однако перед этим очень рекомендую поправить имя dotnet-проекта (это же имя используется для солюшена). Сделать этом можно либо через меню Проект -> Настройки проекта -> Dotnet -> Проект -> Assembly Name, либо через прямое указание в исходниках .godot:

[dotnet]

project/assembly_name="SomeName.WithoutSpaces"

Если этого не сделать, то Godot по умолчанию накидает пробелов между словами и вы получите .sln и .csproj файлы с пробельными именами и фрагментарным camelCase. Одновременно с этим пространства имён в проектах будут без пробелов, и разницу в именах на devops-этапе придётся разруливать руками.

Подключение F#

Далее необходимо открыть .sln в своей IDE и добавить в решение библиотеку классов F# (я предпочитаю называть его <ProjectName>.Core). У нового проекта надо поменять SDK, для этого достаточно открыть .csproj от Godot и скопировать шапку C#-проекта в F#:

<Project Sdk="Godot.NET.Sdk/4.2.1">
	<PropertyGroup>
		<TargetFramework>net6.0</TargetFramework>
		<TargetFramework Condition=" '$(GodotTargetPlatform)' == 'android' ">net7.0</TargetFramework>
		<TargetFramework Condition=" '$(GodotTargetPlatform)' == 'ios' ">net8.0</TargetFramework>

После нужно будет сослаться из C#-проекта на <ProjectName>.Core.

<ItemGroup>
	<ProjectReference Include="ProjectName.Core\ProjectName.Core.fsproj" />
</ItemGroup>

Здесь сразу стоит запомнить, что Godot как-то нестандартно подтягивает зависимости. Обычно dotnet без чьей-либо помощи неявно включает пакеты из проектов-зависимостей, но Godot в этом процессе что-то сломал. Все пакеты, что будут добавлены в F#-проект, должны быть в явном виде добавлены в C#-проект, иначе сборка будет падать. Не проверял данную особенность в отношении связки C# -> C#, но нас она точно касается.

Отладка и настройка запуска

Обычно я редко использую дебаг, однако Godot недоступен из REPL-а, да и в целом является для меня новой средой, так что здесь отладка может быть полезной. Редактор Godot подключается к запускаемой сцене, но F# он дебажить не умеет. Тем не менее он позволяет копаться в актуальном древе сцены (через Панель "Сцена" -> Вкладка "Удалённый"), и я очень рекомендую обратить внимание на эту фичу. Она предназначена для Godot.Node (и её наследников) и покрывает лишь экспортируемые свойства (видные в редакторе). Но при желании можно абузить технологию, формируя собственные сугубо отладочные ноды, и откладывать в них необходимую информацию.

F# надо дебажить при помощи основной IDE. Сначала запускаете сцену в Godot, а потом из IDE подключаетесь к запущенному процессу. Конкретно в VS этот пункт прячется в Отладка -> Присоединиться к процессу.... Это не самый удобный способ, но он полезен, если вы неожиданно загнали какой-то компонент в невалидное состояние и надо, не останавливаясь, изучить его внутренние данные.

Если вы знаете, что собираетесь дебажить, то можно заблаговременно подготовить информацию для подключения. Если пройти в VS по Отладка -> Свойства отладки для проекта <ProjectName>, то откроется окно, в котором можно будет добавить новый профиль с запуском внешнего исполняемого файла и т. д. Переводя на русский, мы можем настроить консольный вызов некоего .exe-файла вместо стандартного запуска (который не работает для библиотек), и по нажатию F5 запустится сцена, а не сообщение об ошибке. От нас требуется указать путь к исполняемому файлу, аргументы вызова и рабочую директорию. Все эти настройки VS сохраняет в файле Properties/launchSettings.json, который можно заполнить самостоятельно.

Если вызывать редактор с аргументом --help (т. е. Godot_v4.2.1-stable_mono_win64.exe --help), то можно получить здоровенную справку и убедиться в том, что Godot вполне могёт в devops. Нас, правда, больше волнуют настройки запуска. Скажем, через --editor можно открыть проект в редакторе, а через Relative/Path/To/Scene.tscn запустить конкретную сцену. Опций там действительно много, и в них имеет смысл погружаться, лишь имея конкретную задачу. Я пока ограничиваюсь небольшим скриптом, который генерирует:

  • профиль для запуска редактора Godot,

  • дефолтной сцены,

  • и каждой сцены в проекте.

[
    Profile.RunEditor
    Profile.RunGame

    let scenes =
        System.IO.Directory.EnumerateFiles(
            godotProjectDirectory
            , "*.tscn"
            , System.IO.SearchOption.AllDirectories
        )
    for fullPath in scenes do
        System.IO.Path.GetRelativePath(godotProjectDirectory, fullPath)
        |> Profile.RunScene
]

Профили интерпретируются следующим образом:

[<RequireQualifiedAccess>]
type Profile =
    | RunEditor
    | RunGame
    | RunScene of ScenePath : string

    with
    member this.CommandLineArgs = [
        "--path"
        "."
        match this with
        | Profile.RunEditor ->
            "--editor"
            //"--rendering-engine"
            //"opengl3"
        | Profile.RunGame -> ()
        | Profile.RunScene path ->
            path
        "--verbose"
    ]
    member this.Name =
        match this with
        | Profile.RunEditor -> "Godot Editor"
        | Profile.RunGame -> "Godot Game"
        | Profile.RunScene path -> $"Godot {path}"

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

У скрипта есть изъян, связанный с тем, что он генерирует профили, которые зависят от расположения Godot_v4.2.1-stable_mono_win64.exe на конкретной машине. Я использую одну и ту же схему взаимного расположения проектов и движка, но в профили попадают абсолютные пути, которые не позволяют безболезненно шарить Properties/launchSettings.json между компами, из-за чего последний попадает в .gitignore. При желании эта кустарщина может быть вылечена регистрацией Godot в Environment.Path.

Godot:
- Engines: // Папка с различными версиями godot.
  - Godot_v{godotVersion}-stable_mono_win64
    - Godot_v{godotVersion}-stable_mono_win64.exe
- Projects: // Папка с проектами.
 - ProjectName:
   - ProjectName.csproj
   - ProjectName.sln
   - ProjectName.Core: 
     - ProjectName.Core.fsproj
     - PrepareLaunchSettings.fsx
   - Properties: // Изначально может отсутствовать.
     - launchSettings.json

В итоге открытие проекта у меня выглядит следующим образом:

  1. Скачиваем репозиторий;

  2. Открываем солюшен в VS;

  3. Запускаем PrepareLaunchSettings.fsx (пропускаем, если профили не протухли с прошлого запуска);

  4. Выбираем Godot Editor в качестве запускаемого профиля;

  5. Запускаем проект без отладки!

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

Первый скрипт сцены и механика проброса

В категориальном аппарате Godot скрипт — это любой файл с кодом класса сцены/ноды на GDScript/C#. То есть речь идёт об обычном аналоге .fs, а не .fsx. Так как в описываемом сценарии .fsx-скрипты больше упоминаться не будут, я решил сохранить терминологию Godot.

По умолчанию сцены в Godot не имеют скриптов («по-русски» Code Behind). Их можно подключить, самостоятельно нажав на соответствующую кнопку. Причём скрипт прикрепляется не к сцене вообще, а к любой ноде в сцене. Скриптов в сцене может быть много при условии, что на каждую ноду приходится не более одного скрипта. Я предпочитаю один скрипт на всю сцену, который прикреплён к корневой ноде, что делает процесс похожим на классическое XAML-based приложение, но с точки зрения Godot, данный подход не является каноном.

Далее, прикрепляемый скрипт может быть на любом языке из поддерживаемых (базово это GDScript и C#, но ещё есть некий GDExtensions). То есть в одной сцене может быть множество скриптов, прикреплённых к разным нодам и написанных на разных языках. Это требует определённых усилий со стороны авторов Godot, но это также позволяет пользователям стравливать сложность по мере роста проекта. Надо понимать, что интеграция F# на данный момент настолько громоздка, что весь этот багаж по большей части будет потерян и его надо будет компенсировать своими силами. Меня этот факт не напрягает, а даже радует, так как таскать инфу с места на место у F# получается гораздо лучше, чем у конкурентов.

Мой алгоритм (акцент на субъективности, а не авторских правах) создания сцены и подключения F#:

  1. Создаём сцену.

  2. В сцене создаём корневую ноду необходимого типа, именуем её MySceneName.

  3. Сохраняем её под тем же именем в одноимённой папке (MySceneName/MySceneName.tscn).

  4. Прикрепляем к корневой ноде на C# (MySceneName.cs), от шаблонов отказываемся, сохраняем в той же папке.

  5. Открываем VS, в F#-проекте создаём MySceneName.fs файл.

  6. В файле объявляем модуль верхнего уровня ProjectName.Core.MySceneName.

  7. Открываем Godot.

  8. Объявляем тип Main, который наследуем от типа из пункта 2.

  9. Оверрайдим метод _Ready заглушкой (ну или Hello world!).

  10. Собираем проект, чтобы в C# прогрузились новые созданные модули, типы и методы.

  11. Открываем в VS MySceneName.cs, заменяем тип предка на ProjectName.Core.MySceneName.Main.

  12. Оверрайдим метод _Ready дефолтной реализацией, которая вызывает метод предка.

  13. Готово.

Итоговый код F#:

module ProjectName.Core.MySceneName

open Godot

type Main () =
    inherit Node2D()

    override this._Ready () =
        GD.Print "Hello world!"

Итоговый код C#:

using Godot;
using System;

public partial class MySceneName : ProjectName.Core.MySceneName.Main
{
	public override void _Ready() => base._Ready();
}

C#-тип помечен как partial. Это обязательное требование со стороны Godot, без его соблюдения проект не компилируется. Вызвано это тем, что Godot.SourceGenerators (идут вместе с Godot.NET.Sdk) должны создать ещё несколько файлов, где указанный тип через partial будет расширен методами, отвечающими за интероп с экземпляром (вида HasMember/CallMember).

Godot принимает решения о вызове на основе своей проекции мира. Если в этой проекции ничего не сказано про методы _Ready, _Process и т. д., то с точки зрения движка их не существует. Это не значит, что, вызвав _Ready() в dotnet-коде , вы словите какой-нибудь MissingMemberException. Такой вызов пройдёт без ошибок. Это значит, что, когда движок поместит данную ноду в древо, он не увидит у неё метода _Ready и сэкономит наносеки, проигнорировав его вызов. То же самое должно произойти, если вызвать метод _Ready из GDScript. Где-то в недрах HasGodotClassMethod и InvokeGodotClassMethod вызов завернётся на ошибку.

Godot ничего не знает об F#. Генераторы не умеют писать .fs-файлы, да к тому же их никто не вызывает. В резульатте F#-типы оказываются без интероп-методов. Движок в рантайме не распознаёт методы в нодах и не утруждает себя заходом в dotnet.

Выходит, что связка C# -> F# вызвана не тем, что мы не можем привязать к ноде F#-скрипт (хотя это тоже правда), а тем, что мы не можем воспользоваться встроенным генератором в отношении F#-типа. Я пробовал воспроизвести генератор самостоятельно, но оказалось, что пара низкоуровневых фич в F# просто не заводятся на уровне языка. И это положение не измениться, так как официальная позиция F# (выраженная Светочем Нашим Саймом в ряде ишуев) сводится к: «Вот с этим? Идите наC#.».

С результатом генерации можно ознакомиться через ProjectName -> Зависимости -> Анализаторы -> Godot.SourceGenerators -> _ -> <Имя типа>_<Суффикс генератора>.generated.cs. Код там довольно тупой, и если всё работает штатно, то системно заглядывать в него смысла нет. Сводится всё к описанию типов в альтернативной нотации, как если бы мы отрефлексировали типы и выразили их в каких-нибудь json-схемах. Я не заметил там точек, за которые можно было бы зацепиться и наваять что-нибудь эдакое. Эта штука сильно проще, чем DependencyProperty.

Кроме этого, даже если бы мы смогли переписать генерацию на F#, то возникла бы проблема с отсутствующим partial. Хитро сшивать рукописные исходники с машинными в рамках одного файла я не хочу, ибо слишком высок риск ввода дополнительных ограничений. Генерировать потомка (или предка, на основе «послезнания») я тоже не хочу, ибо в этом случае у нас всё равно появляется дополнительный тип, который проигрывает C#-наследнику, так как проще сгенерировать тип-заглушку на C#, а дальше официальный генератор Godot настрогает все необходимые обвязки самостоятельно.

После всего этого, когда Godot возьмёт C#-тип в оборот, он даст его интероп-описание, опираясь на описание его предка. Проблема в том, что если предок был определён в F#, то описание предка будет неактуальным. Можно сказать, что все F#-типы в цепочке наследования будут пропущены, пока мы не столкнёмся со следующим C#-типом. Пока что самый простой способ обойти это ограничение — повторно переопределить «F#-члены» в C#. Выглядит неэстетично, лично меня коробит, но задачу свою решает.

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

Общие принципы интеропа

Godot допускает, что иногда будет вызывать обычные ненаследуемые методы и свойства наших типов, если этого потребует GDScript или сцена (например, в результате подписки на сигнал). Чтобы было быстрее, вызовы производятся не через рефлексию, а через заранее подготовленный контракт. Часть контракта собирается через сумму контрактов предков. Остальную формируют генераторы на основе свойств и методов, определённых непосредственно в типе. Генераторы создадут обёртки для каждого встреченного члена, если посчитают, что его сигнатуру можно описать в доступных движку категориях. Этот список несколько шире, чем совокупность примитивов и наследников GodotObject, так как движок умеет работать с абстрактными enum-ами и, наверное, с чем-то ещё.

Судя по всему, генератор подхватывает переопределённые методы просто из-за того, что они есть и они Godot friendly, а не потому, что он ждёт именно их. Во всяком случае я не увидел разницы в контракте переопределённого метода и одноимённого ему перекрытия. Генератор также игнорирует атрибуты private, так что инкапсуляция хромает. Способов указать на нежелательность экспорта Godot не даёт.

Забавно, но в нашем случае мы получаем больше контроля от наличия C#-прокладки, чем от её отсутствия. Если мы не продублируем метод, то он не появится в контракте, а, значит, будет недоступен для движка:

member this.VisibleMethod (input : int) = ()
member this.InvisibleMethod (input : int) = ()
// Обращаем внимание на слово `new`.
public new void VisibleMethod(int input) => base.VisibleMethod(input);

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

match methodName, args.Count with
| "SomeMethod", 1 -> 
    Vartiants.toInt32 args.[0]
    |> this.SomeMethod
    |> Variants.fromUnit
// | .. -> ..
| _ -> 
    base.InvokeGodotClassMethod(method, args)

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

Важно различать системные вызовы от диких. _Process, _Draw и т. п. методы вызываются напрямую, но какой-нибудь таймер каждый раз дёргает _on_enemy_spawn_timer_timeout именно через InvokeGodotClassMethod. В этом можно убедиться через дебагпоинты в .generated.cs-файлах.

Экспортируемые свойства и атрибуты Godot

Godot позволяет маркировать некоторые свойства типа как доступные для редактора. Для этого свойство должно быть промаркировано атрибутом Export. Вешать атрибуты в F# бесполезно, генератор слеп, так что рабочей схемой является объявление нового одноимённого свойства с этим же атрибутом. Сначала объявляем его в исходнике:

member val Value = 42 with get, set

А потом перекрываем его в проекции:

[Export]
public new int Value
{
	get => base.Value;
	set => base.Value = value;
}

Лишний Export в F# никак не скажется на результате, так что писать его или нет, решаете сами. С учётом гипотетического генератора я завёл отдельный MustBeExportAttribute, который ставлю на свойства в F#.

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

С другой стороны, интероп не поддаётся какой-либо кастомизации, так что дублирование свойств позволяет слегка шаманить с конвертацией DU, option и list. Это избавляет нас от некоторого мусора, но усложняет логику в C#. Для таких случаев я предпочёл бы использовать адаптеры с отдельной описательной моделью. Это особенно актуально ввиду дополнительных опций кастомизации видимости и группировки свойств в редакторе, которые в данный момент растянуты между атрибутами и оверрайдом.

Сцены, исчерпывающиеся скриптами

В случае корневых нод сцен создание экземпляра происходит на стороне Godot (ну либо через PackedScene), что почти автоматически заменит дикого степного F#-предка на огодотнутого C#-потомка. В этом случае нам не надо беспокоиться о конструкторе или фабрике. Однако в моей практике, типы с .tscn-файлами — это скорее исключение, чем норма. Я спавню большое количество типов, которые существуют только в виде скриптов. Так что между module ProjectName.Core.MySceneName и type Main может быть определено ещё десяток или два Godot-типов поменьше. Где-то пятая часть из них может содержать переопределённые методы.

Создавать их напрямую нельзя, так как для движка такие экземпляры будут пустыми. Нам снова нужны потомки, пропущенные через жернова генераторов. В C# в этом месте прикрутили бы DI, но я вряд ли буду его использовать где-либо ещё, так что лично мне хватит и обычного IVar мутабельного поля с фабрикой непосредственно в типе:

type Minor () =
    inherit Node2D()

    static member val Factory =
        // Func только из-за того, что C# слишком вербозно описывает FSharpFunc и Unit.
        System.Func<Minor>(fun () -> failwith "Factory is empty!")
        with get, set

    static member create () = Minor.Factory.Invoke ()

    override this._Ready () =
        GD.print "Hello from Minor!"

Предполагается, что вместо конструктора Minor будет использоваться метод create:

this.AddChild ^ Minor.create()

Далее надо определить инициализацию фабрики в потомке:

public partial class Minor : GodotFSharp.Core.Main.Minor
{
    public static void Initialize()
    {
        GodotFSharp.Core.Main.Minor.Factory = () => new Minor();
    }

    public override void _Ready() => base._Ready();
}

И, наконец, вызвать Minor.Initialize в Program.Main. Проблема в том, что никакого Program.Main не существует, так как Godot вообще не даёт нам привычного EntryPoint-метода. Вместо него, в Godot есть механизм Autoload, который предназначен для инициализации глобальных singleton-ов. В контексте движка это ноды, которые существуют независимо от открытой сцены, но к которым всегда есть доступ через this.getNode "root/SingletonName". Они предназначены для хранения глобального состояния, типа игровых настроек, профиля игрока и т. д. И этим объясняется некоторая неказистость фичи применительно к нашей задаче.

Нам нужно создать ещё один тип ноды, но теперь только на стороне C#. Описать в ней процедуру инициализации общую для всего проекта и вызвать её в методе _Ready():

public partial class EntryPoint : Godot.Node
{
    private bool Initialized = false;

    private static void Initialize()
    {
        // Упоминаем все типы.
        Minor.Initialize();
        // Либо заводим DI классическим способом.
    }

    public override void _Ready()
    {
        if (!Initialized)
        {
            Initialized = true;
            Initialize();
        }
    }
}

Далее в Godot-редакторе следует открыть Проект -> Настройки проекта..., выбрать вкладку Автозагрузка, открыть файл EntryPoint.cs и Добавить его в список глобальных переменных. Теперь, независимо от выбранной сцены, будет создаваться EntryPoint, который настроит Minor и другие типы.

Вместо сигналов

Сигналы в Godot выполняют роль событий, но их реализация и использование достаточно сильно отличаются от привычных нам IEvent<_,_>. Вместо того, чтобы выразить сигналы в выделенных объектах (как в F#), создатели размазали их по экземпляру в виде архаических конструкций. Из-за этого F# нельзя использовать как первичный источник данных, приходится обмазываться абстрактными методами и реализовывать их в потомке. Выглядит всё жутковато, а попытки облагородить оборачиваются библиотечным кодом, чего в рамках данной статьи я всячески пытался избежать.

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

  • Можно и нужно использовать сигналы, которые уже есть в существующих типах.

  • Свои сигналы имеет смысл определять только в том случае, если предполагается интероп с GDScript.

  • Во всех остальных случаях лучше использовать обычные события, Hopac, ECS и т. д.

Заключение

Основные моменты подключения F# к Godot я осветил. Получилось C#-ориентированно, но этого должно хватить, чтобы пройти туториалы, не изобретая велосипед. Небольшой пример проекта на основе этих данных находится здесь.

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

  • Godot изобилует конструкциями, рассчитанными на ООП-шный стиль. Благодаря мультипарадигмальности F# их можно и нужно эксплуатировать пока это выгодно, и по необходимости отбрасывать, когда количество или сложность бизнес-логики превысят комфортные объёмы.

  • Godot намертво скрепляет собственный контракт с синтаксическими конструкциями. Это C#-way, это хроническое, и оно не лечится. К счастью, у нас есть ниша в виде смычки между F# и C#, которая позволяет держать этот дефект в загончике. Если чувствуете, что какая-то часть контракта (не логики) начинает уходить в отдельный объект, не сопротивляйтесь. Скорее всего, это оправдано естественным порядком вещей.

  • Godot ратует за композицию, но ни в официальных, ни в фанатских руководствах эта линия практически не развивается. Если вы новичок и слабо понимаете, о чём идёт речь, то рекомендую первое время сводить данную концепцию к одной практической максиме: Создавайте гораздо больше типов, чем привыкли. Этого хватит, чтобы усреднённая сцена начала распадаться на 5-6 крупных компонентов с набором связей между ними, которые все вместе оперируют ворохом типов поменьше. Подчеркну, что речь не идёт о разбитии сцены в Godot. Все эти компоненты спокойно размещаются между module и type Main и обретают полную самостоятельность лишь при необходимости.

  • Если вы в состоянии сформировать подходящий DSL, то редактор Godot нужен будет только для визуального позиционирования объектов в пространстве, и то далеко не всех. Всё остальное можно делать, не выходя из F#.

Автор статьи @kleidemos


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
— 15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS

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