
В прошлой главе мы доделали все ключевые моменты DSL, которые были необходимы для более-менее комфортной работы с Godot. Далее я обещал финишировать эпопею заходом в реактивное программирование, но этот блок глав было решено отложить и опубликовать отдельно.
Дело в том, что на зимних каникулах я ходил в народ и внезапно обнаружил, что в данный момент моему циклу нужно не продолжение, а хорошая пачка «чекпоинтов». Поэтому мне пришлось отложить все заряженные ружья на потом, чтобы зафиксировать текущий прогресс в виде нескольких репозиториев с очень простыми проектами. Собственно разбору этих проектов и будут посвящены оставшиеся части цикла.
Сначала мы соберём всю инфраструктуру (упомянутую в рамках цикла) на базе проекта из этой статьи. Потом несколькими способами напишем одну и ту же сцену и в конце добьём всё дело ещё щепоткой инфраструктуры, о которой я упоминал лишь поверхностно, но без которой не могу жить.
В следующий раз мы продолжим возвращением к тайловым мирам. Мне вменяли в вину, что в начале цикла я обещал не совсем то, что сделал по итогу, так что я собрал чистовую версию вот такой песочницы спецом под Хабр:

Она написана с применением только тех приёмов, что уже были хотя бы надкусаны в цикле, поэтому концовка получилась очень щадящая.
Оглавление
Приквел
Шестидесятилетний заключённый и лабораторная крыса. F# на Godot
Часть 13. Общий сбор // мы здесь
Выпиливание C#
В качестве основы я использовал проект отсюда, но вы можете пропустить эту фазу и сразу взять итоговый вариант отсюда. Чтобы избавиться от C#, нам надо зажать F#-проект меж двумя C#-проектами по схеме, описанной здесь. В общих чертах она видна на следующем скрине:

Оба .csproj самостоятельной роли не играют и выступают лишь в качестве прокси между движком и F#.
ProjectName.csproj
Первое, что бросается в глаза, — мы избавились от .cs-файлов, отвечающих за конкретные сцены и их дочерние ноды, так как «прикрепление скриптов» полностью уехало в .tscn или .fs. Благодаря этому содержимое проекта ProjectName.csproj консервируется. Оно не поменяется при появлении новых сцен, нод и т. д. за исключением тех случаев, когда нам потребуется добавлять ноды кастомных типов через редактор Godot.
«Прикрепление скриптов» в этой версии слегка отличается от того, что я показывал ранее (ReadyGroups + EntryPoint). Оба варианта работают, но новый визуально проще. Из-за этого он чуть легче стреляет в ногу, но этот р��ск был признан терпимым или даже по-дарвиновски справедливым.
В исходном проекте лежит лишь один C#-тип SummonFSharp:
using Godot; [GlobalClass] [Icon(@"res://FSharpLogo.png")] public partial class SummonFSharp : Node { public override void _Ready() => GodotFSharp3.Core.EntryPoint.summonFSharp(this); }
Как следует из названия, задача этой ноды — призвать F#. В практическом плане всё сводится к вызову конкретной функции из модуля EntryPoint в момент, когда SummonFSharp проходит через фазу _Ready. После этого нода ничего не делает. Она просто остаётся в древе и используется как точка инфильтрации.
Содержимое EntryPoint.summonFSharp имеет небиблиотечную природу и является сферой ответственности автора конкретного проекта. К нам приходит нода, и мы должны решить, что с ней делать. Явных ключей как в варианте из 10 главы здесь нет, но можно использовать в качестве них имя ноды или какие-то данные из меты или другого окружения:
module GodotFSharp3.Core.EntryPoint open Godot let summonFSharp node = match string (node : Node).Name with | "EntryPoint" -> do () | "MyScene" -> MyScene.initWhenReady ^ node.GetParent() | unexpected -> GD.print $"unexpected node name: %A{unexpected}"
В жизни обычной ноды фаза _Ready происходит всего один раз, сразу после развёртывания в древе, когда _Ready потомков и верхних сиблингов уже отработали, а _Ready родителя и нижних сиблингов ещё нет. Это даёт нам понимание «Когда?», а сама нода отвечает на вопросы «Кто?» и «Где?». Этой информации должно быть достаточно, чтобы выбрать нужный инициализатор и докрутить окружение ноды не выходя из F#.
Например, мы можем добавить в проект сцену EntryPoint\EntryPoint.tscn:

Её нужно будет зарегистрировать в автозагрузке, и тогда движок создаст экземпляр этой штуки до того, как будет запущена основная сцена. Это значит, что мы получим вызов EntryPoint.summonFSharp с нодой по имени EntryPoint до всех остальных, чем можно воспользоваться для вызова статических инициализаторов. Например, для Estragonia (Avalonia внутри Godot):
module GodotFSharp3.Core.EntryPoint open JLeb.Estragonia open Avalonia open Godot let summonFSharp node = match string (node : Node).Name with | "EntryPoint" -> AppBuilder .Configure<App>() .UseGodot() .SetupWithoutStarting() ...
В обычных сценах SummonFSharp можно вложить в качестве потомка в целевую ноду. Например, так может выглядеть древо MyScene:

Подробнее о нём поговорим ниже.
ProjectName.Utils.csproj
ProjectName.Utils — это инкубатор инфраструктурных типов. Его обитатели проходят обработку генераторами Godot до попадания в ProjectName.Core.fsproj, благодаря чему их override видны движку, и он на них реагирует должным образом. Движок всё ещё игнорирует переопределения в F#, но нам это не важно, так как если в F# переопределить только то, что уже было переопределено в C#, то «карта» переопределений типов совпадёт. На этом держится вторая половина интеграции с Godot.
Сначала в C# мы готовим ноды, которые умеют реагировать на «системные» события движка, после чего наследуемся от них в F# и размещаем наследников там, где в обычных условиях использовали бы override. В ProjectName.Utils у нас есть пачка типов подобного вида:
namespace Godot.Implements { public partial class Implement_Process : Godot.Node { public override void _Process(double delta) => base._Process(delta); } }
Один метод — один тип. Меня подмывает сложить их в один файл, но в чужой монастырь со своим уставом не лезут.
Далее где-то в недрах GodotUtils.fs лежит набор хелперов:
module GD = ... type Implements = static member _process action = { new Godot.Implements.Implement_Process() with override this._Process delta = base._Process delta action this delta }
Один тип — один метод. Меня из праздного любопытства спрашивали, будет ли какой-то профит от того, что мы наплодим типы с большим числом методов. Для предпочитаемого мной сценария я никакой выгоды не просматриваю, но допускаю, что мне чего-то не видно, так что вопрос остаётся открытым.
На этом взаимодействие с C# заканчивается. Больше он нам не пригодится. Оба C#-ных проекта можно переносить из одного решения в другое лишь вовремя поправляя названия .csproj-файлов и неймспейсы. Остальное остаётся без изменений, и я очень надеюсь, что мне больше не придётся касаться этой темы в будущем.
ProjectName.Core.fsproj
Единственный .fsproj выглядит плюс-минус ожидаемо. Utils.fs я собрал чисто под данный проект, так как не хотел пугать людей спящим монстром на несколько тысяч строк. Папку с расширениями для Godot я тоже собирал по мере необходимости (для этого проекта и песочницы), но какая-то часть GodotUtils может быть загадкой даже для меня, так как она была выдрана из первого F#-порта тайловых миров.
Подключение MyScene.fs

Ранее мы прикрепляли .cs-скрипт к сцене через движок. Теперь вместо этого нам надо добавить в неё ноду SummonFSharp с именем MyScene:

Далее в EntryPoint.fs этот ключ надо поймать и вызвать нужный инициализатор:
| "MyScene" -> MyScene.initWhenReady ^ node.GetParent()
Фаза _Ready в SummonFSharp отрабатывает до того, как отрабатывает _Ready всей сцены (родителя SummonFSharp или более поздних сиблингов). Очевидно, что наше развёртывание не может быть выполнено в условиях, когда часть участников ещё не готовы к коммуникации (движок умеет ронять такие вызовы) или вообще не существуют. Нам нужно дождаться полной готовности и только потом действовать:
module IDisposable = let handleSignalOneTime trigger = let child = IDisposable.composite () SignalHandler.create child.Add ^ fun node message -> trigger node message child.Dispose() node.Ready'Handler <- IDisposable.handleSignalOneTime ^ fun _ _ -> init node
Одновременно с этим надо помнить, что мы можем вызвать инициализатор на уже готовой ноде. Такая нода не будет сигнализировать о _Ready повторно, так как это событие произошло ранее и более не повторится. Так что нам надо учитывать и «готовый», и «неготовый» сценарии:
module GD = ... let whenReady node action = if (node : Node).IsNodeReady() then action () else node.Ready'Handler <- IDisposable.handleSignalOneTime ^ fun _ _ -> action ()
В конечном итоге инициализатор MyScene будет выглядеть как-то так:
module MyScene ... let initWhenReady main = GD.whenReady main ^ fun () -> new Ready(main, Name = "Ready") |> main.AddChild
Замена цепочки наследования
В модуле MyScene у нас было два типа с переопределёнными методами. И чтобы донести информацию о них до движка, приходилось изрядно попотеть. Сейчас это всё в прошлом, так что я не буду разбирать детали повторно. Если кому надо, идите в приквел цикла, там все дефекты интеграции описаны достаточно подробно. Мы же продолжим с чистого листа.

Нода Minor отвечает за схлопывающийся шарик:
type Minor (disposables : CompositeDisposable) as this = inherit Node2D() let color = Color.FromHsv(GD.Randf(), 0.9f, 0.95f) do base.Name <- $"Minor #%s{color.ToHtml false}" disposables.Add ^ IDisposable.create this.QueueFree let mutable health = 1f do this.AddChild ^ GD.Implements._process ^ fun _ delta -> health <- health - float32 delta * 0.3f if health <= 0f then GD.print $"{string this.Name} -> Free" disposables.Dispose() else this.QueueRedraw() do this.Draw'Handler <- disposables.HandleSignal ^ fun _ () -> this.DrawCircle(Vector2.Zero, this.Radius * health, color) member val Radius = 32f with get, set
disposables : CompositeDisposable отвечает за все процессы и ресурсы, которые надо уничтожить при высвобождении ноды. Причём конкретно здесь мы уверены, что высвобождать ноду будем мы, а не какой-то внешний актор, поэтому this.QueueFree кладётся внутрь disposables. Позднее, вызывая disposables.Dispose, мы убьём и ноду, и подписки.
Вообще, ноды поддерживают интерфейс IDisposable, так что в теории их можно класть в CompositeDisposable напрямую, но я так никогда не делаю, так как по логике вещей Dispose ноды соответствует жёсткому ожидающему Free, а не мягкому отложенному QueueFree. В документации авторы движка просят использовать именно QueueFree во всех сценариях, где не предполагается срочное выпиливание ноды. Как выглядят эти «срочные» сценарии, я не знаю, но подозреваю, что грамотное размещение CompositeDisposable на линиях коммуникации может превратить любую срочную ситуацию в нечто менее чрезвычайное, во что-то, что «само пройдёт».
Вместо старого override this._Process delta = у нас используется уже хорошо знакомая схема с this.AddChild ^ GD.Implements._process. В ней прокси-потомок работает с внутренним состоянием Minor как со своим собственным, но движку об этом знать совершенно не обязательно. В 99% кейсов это безопасно, однако в случае _Draw эта схема не работает, так как циклы перерисовки потомка и предка не совпадают. Кроме того, я не очень понимаю, как именно должна выглядеть имплементация GD.Implements._draw. В итоге _Draw был заменён подпиской на сигнал и внезапно для меня метод DrawCircle выполнил свою задачу без стандартных для него жалоб на несвоевременный вызов. (Да, я знаю, что об этом прямо написано в документации, но инерция моего предыдущего опыта всё затёрла.)
Нода Ready отвечает за инициализацию всей сцены:
type Ready (main : Node) = inherit Node() let radius = main.getNode("Config/Minor") .GetMeta("Radius", Variant.CreateFrom 64) .AsInt32() |> float32 let disposables = IDisposable.composite() let canvas : Control = main.getNode "UI/ClickCanvas" //do canvas.GuiInput'Handler <- disposables.HandleSignal ^ fun _ ev -> do canvas.AddChild ^ GD.Implements._unhandledInput ^ fun _ ev -> match ev with | LeftMouseClick position -> canvas.AddChild ^ new Minor( disposables.CreateChild() , Position = position , Radius = radius ) canvas.GetViewport().SetInputAsHandled() | _ -> () do main.AddChild ^ GD.Implements._notification ^ fun _ code -> match int64 code with | Node.NotificationPredelete -> disposables.Dispose() | _ -> () let initWhenReady main = GD.whenReady main ^ fun () -> new Ready(main, Name = "Ready") |> main.AddChild
Ранее обработка нажатий ЛКМ была привязана к конкретному методу через редактор (и .tscn). Теперь же мы самостоятельно оформляем подписку через canvas.AddChild ^ GD.Implements._unhandledInput. Причём такая подписка чуть эффективнее, чем canvas.GuiInput'Handler <- disposables.HandleSignal, так как отвечает только за бесхозные события (правда в нашей сцене, где нет конкурирующих обработчиков, все события такие).
Обработчик main.AddChild ^ GD.Implements._notification здесь дан лишь для примера. Этот код позволяет привязаться к моменту смерти ноды и забрать с собой кого-нибудь, о ком движок мог забыть или не знать, например, disposables. Других способов поймать этот момент не существует, хотя я ожидал, что в системе будет какой-нибудь специализированный сигнал. Проблема этого кода в том, что нотификаций в движке эпически много, и этот обработчик может вызываться сотни раз в секунду без какой-либо пользы для дела. Так что по-хорошему не надо привязываться к NotificationPredelete. Вместо этого нам надо проектировать сцены так, чтобы их высвобождение контролировалось нами, а не движком. То есть мы убиваем disposables, а QueueFree и движок догоняют.
В оригинальной статье радиус всех создаваемых пузырей можно было задать в редакторе через свойство Radius типа MyScene.cs. Это удобно, но не когда пишешь на F#. Здесь мы вынуждены как-то изгаляться. В тяжёлом варианте можно-таки завести прокси-тип для конфигурации в ре��акторе, но в более простых случаях можно использовать мету в качестве универсального хранилища конфигов. В данном конкретном случае я создал несколько промежуточных нод для выражения иерархии данных (но это не обязательно, нечто вроде Config_Minor_<_> в рутовой ноде тоже сработает), после чего задал мету Radius = 128:

// То же самое в виде кода. do main.AddChild ^ FG.Node( Name = "Config" , Children'AddRange = [ let minor = FG.Node(Name = "Minor") minor.SetMeta("Radius", Variant.CreateFrom 128) minor ] )
После чего нашёл нужную ноду в MyScene и выдрал из неё искомое значение:
let radius = main.getNode("Config/Minor") // Второй параметр -- это значение по умолчанию // на случай, если такое свойство не задано. .GetMeta("Radius", Variant.CreateFrom 64) .AsInt32() |> float32
Мета поддерживает большое количество базовых типов движка, в том числе строки, пути к нодам и даже словари, что позволяет пробрасывать произвольную информацию из редактора Godot в F#. Ввиду того, что я использую редактор только для особых случаев (типа редактора карт или куклы персонажа), мне этой схемы оказывается вполне достаточно, но рискну предположить, что у кого-то она вызовет отторжение. В последнем случае я пока бессилен как-либо исправить положение, так что далее мы сосредоточимся на плюшках, которые исключают использование редактора.
Собираем сцену из кода
MyScene не относится к тому типу сцен, которые я собираю через редактор. Она слишком проста и математична для этого. Вот так может выглядеть её сборка «идентичная натуральной»:
module GodotFSharp3.Core.MyScenePrepareViaCode open Godot open System.Reactive.Disposables type Control with member this.FullRect () = this.SetAnchorsPreset Control.LayoutPreset.FullRect this member this.Center () = this.SetAnchorsPreset Control.LayoutPreset.Center this.GrowHorizontal <- Control.GrowDirection.Both this.GrowVertical <- Control.GrowDirection.Both this let prepare (main : Node) = do main.AddChild ^ FG.Node( Name = "Config" , Children'AddRange = [ let minor = FG.Node(Name = "Minor") minor.SetMeta("Radius", Variant.CreateFrom 128) minor ] ) do main.AddChild ^ FG.CanvasLayer( Name = "UI" , Children'AddRange = [ FG.ColorRect( Name = "ClickCanvas" , Color = Colors.White , MouseFilter = Control.MouseFilterEnum.Pass ).FullRect() FG.Label( Text = "Кликните в произвольном месте." , Name = "Hint" , LabelSettings = FG.LabelSettings( FontColor = Colors.Black ) ).Center() ] ) let initWhenReady main = GD.whenReady main ^ fun () -> prepare main
Здесь даже промежуточный тип не понадобился, всё ограничилось одной функцией.
Соответствующая сцена в редакторе может выглядеть так:

В корне у нас Node2D нода, внутри которой лежат две ноды SummonFSharp с именами:
"MyScenePrepare""MyScene"
Порядок нод важен, так как именно в таком порядке будут разыгрываться инициализаторы. И порядок перечисления шаблонов здесь ни на что не повлияет:
| "MyScene" -> MyScene.initWhenReady ^ node.GetParent() | "MyScenePrepare" -> MyScenePrepareViaCode.initWhenReady ^ node.GetParent()
Не всегда комбинирование ключей в редакторе имеет смысл. Если сценарий слишком индивидуален или он имеет взаимообусловленную бизнес-логику, то его можно повесить на один ключ:
| "MySceneMix" -> let main = node.GetParent() MyScenePrepareViaCode.initWhenReady main MyScene.initWhenReady main
Когда структура сцены в редакторе вырождается до связки из двух нод, можно задуматься о том, насколько целесообразно иметь корневую ноду. Мы можем её выкинуть и поставить на её место SummonFSharp:

А недостающий Node2D создать непосредственно в обработчике:
| "MySceneMix" -> let main = FG.Node2D(Parent = node) MyScenePrepareViaCode.initWhenReady main MyScene.initWhenReady main
Технически у нас получается слегка инвертированное древо, так как Node2D лежит в SummonFSharp, а не наоборот, но для корневых сцен это не должно иметь значения. Конкретно в нашем случае двухмерность корня никак не используется, поэтому инициализаторы можно натравить на сам SummonFSharp:
| "MySceneMix" -> MyScenePrepareViaCode.initWhenReady node MyScene.initWhenReady node
Code First
Очевидно, что не в образовательных целях нет смысла разделять сборку MyScene на макет и бизнес-логику. Всё можно целиком описать на F#:
module GodotFSharp3.Core.MySceneOnlyCode open Godot open System.Reactive.Disposables type Minor (disposables : CompositeDisposable) as this = inherit Node2D() let color = Color.FromHsv(GD.Randf(), 0.9f, 0.95f) do base.Name <- $"Minor #%s{color.ToHtml false}" disposables.Add ^ IDisposable.create this.QueueFree let mutable health = 1f do this.AddChild ^ GD.Implements._process ^ fun _ delta -> health <- health - float32 delta * 0.3f if health <= 0f then GD.print $"{string this.Name} -> Free" disposables.Dispose() else this.QueueRedraw() do this.Draw'Handler <- disposables.HandleSignal ^ fun _ () -> this.DrawCircle(Vector2.Zero, this.Radius * health, color) member val Radius = 32f with get, set let (|LeftMouseClick|_|) (ev : InputEvent) = match ev with | :? InputEventMouseButton as ev -> if ev.Pressed && ev.ButtonIndex = MouseButton.Left then Some ev.Position else None | _ -> None type Control with member this.FullRect () = this.SetAnchorsPreset Control.LayoutPreset.FullRect this member this.Center () = this.SetAnchorsPreset Control.LayoutPreset.Center this.GrowHorizontal <- Control.GrowDirection.Both this.GrowVertical <- Control.GrowDirection.Both this type Ready (main : Node) = inherit Node() let radius = 64f let disposables = IDisposable.composite() let canvas = FG.ColorRect( Name = "ClickCanvas" , Color = Colors.White , MouseFilter = Control.MouseFilterEnum.Pass ) do main.AddChild ^ FG.CanvasLayer( Name = "UI" , Children'AddRange = [ canvas.FullRect() FG.Label( Text = "Кликните в произвольном месте." , Name = "Hint" , LabelSettings = FG.LabelSettings( FontColor = Colors.Black ) ).Center() ] ) //do canvas.GuiInput'Handler <- disposables.HandleSignal ^ fun _ ev -> do canvas.AddChild ^ GD.Implements._unhandledInput ^ fun _ ev -> match ev with | LeftMouseClick position -> canvas.AddChild ^ new Minor( disposables.CreateChild() , Position = position , Radius = radius ) | _ -> () do main.AddChild ^ GD.Implements._notification ^ fun _ code -> match int64 code with | Node.NotificationPredelete -> disposables.Dispose() | _ -> () let initWhenReady main = GD.whenReady main ^ fun () -> new Ready(main, Name = "Ready") |> main.AddChild
Так как ноды создаём мы, то и искать их по дереву не надо. То же самое касается конфигов, передаваемых через мету. Всё можно задать сразу из кода. Кроме того, я не имею привычки раздавать имена нодам, которые не собираюсь искать.
Не забываем про ещё один .tscn-файл и привязку ключа в EntryPoint:
| "MySceneOnlyCode" -> MySceneOnlyCode.initWhenReady node
Инициализация без .tscn
Когда сцен много, но они все выродились до SummonFSharp(Name = "YetAnotherKey"), возникает непреодолимое желание стартовать сразу с ключа без создания нового .tscn. В мире десктопных приложений такое принято реализовывать через аргументы командной строки, и здесь мы поступим также. Мы создадим ещё один .tscn с SummonFSharp с именем "CmdlineUserArgs":

После добавим ещё один ремап в EntryPoint.fs, но уже на базе OS.GetCmdlineUserArgs():
| "CmdlineUserArgs" -> OS.GetCmdlineUserArgs() |> List.ofSeq |> function | "MySceneMix" :: [] -> MyScenePrepareViaCode.initWhenReady node MyScene.initWhenReady node | "MySceneOnlyCode" :: [] -> MySceneOnlyCode.initWhenReady node | other -> GD.whenReady node ^ fun () -> node.AddChild ^ FG.Label( Text = sprintf "Unexpected cmdline user args: %A" other )
Запуская сцену CmdlineUserArgs\CmdlineUserArgs.tscn с разными аргументами, мы будем провоцировать запуск разных инициализаторов. Осталось прикрутить эти аргументы к VS. В коде PrepareLaunchSettings.fsx надо руками определить набор дополнительных профилей, ведущих к запуску с определёнными параметрами:
let customScenes = [ let inline (!) name path args = {| PublicName = name Path = path Args = args |} !"MyScene Mix" "CmdlineUserArgs\\CmdlineUserArgs.tscn" [ "MySceneMix" ] !"MyScene OnlyCode" "CmdlineUserArgs\\CmdlineUserArgs.tscn" [ "MySceneOnlyCode" ] ]
После чего включить их в общий перечень профилей:
[ Run.Editor Run.Game let scenes = System.IO.Directory.EnumerateFiles( godotProjectDirectory , "*.tscn" , System.IO.SearchOption.AllDirectories ) for fullPath in scenes do System.IO.Path.GetRelativePath(godotProjectDirectory, fullPath) |> Run.Scene // CustomScene добавляйте сюда. for scene in customScenes do Run.CustomScene(scene.PublicName, scene.Path, scene.Args) ] |> ...
Дальше этот .fsx файл надо запустить (можно через Alt + Enter), и он сгенерирует нужные профили, которые можно будет выбрать в IDE:

Теперь при добавлении новой сцены нам будет достаточно добавить ремап в EntryPoint и профиль в PrepareLaunchSettings.fsx (с перезапуском скрипта).
Обращаю внимание, что оба компонента такого запуска находятся в зоне F#. Это означает, что все сложности, которые нам захочется добавить, можно описать на привычном языке. Это не комбайны CICD, где можно внезапно потратить пару часов на выяснение того, как скормить противнику массив строк, содержащих пробелы.
Например, нам может понадобиться определить сцену, которую должен запускать редактор. Формально в нём есть настройки командной строки, но нам будет проще их проигнорировать и разрешить всё силами F#. Мы можем сказать, что отсутствие аргументов (= []) должно трактоваться как выбор такой-то сцены:
| [] // <- Отсутствие аргументов. | "IsometricGrid" :: [] -> Sandbox.initWhenReady ev.Source
И тогда запуск CmdlineUserArgs.tscn из редактора будет приводить к запуску Sandbox.initWhenReady. В принципе, на этом можно построить релизную версию игры. Как минимум на служебных прогах такое работает без заметных проблем.
В чём различие между запусками из редактора и из IDE?
Некоторые считают, что запуск из IDE — излишне сложная операция для новичков и им желательно обходиться без неё. Это ошибочное суждение. Во-первых, Godot не умеет в нормальный Debug, а раз в несколько дней он нужен даже F#-исту. Во-вторых, редактор Godot как-то криво владеет кешами dotnet, из-за чего он имеет привычку пересобирать по полной то, что VS умеет пересобирать по частям. Ситуация вроде как улучшается с выходом новых версий движка, но сейчас запуск из редактора всё ещё ощутимо медленнее, чем запуск из IDE.
При этом, редактор Godot всё равно стоит держать открытым. Дело в том, что он не умеет подтягивать ресурсы (изображения, шейдеры, шрифты, звуки и т. д.) автоматом по команде запуска из IDE. Придётся заглядывать в редактор каждый раз, когда мы меняем состав ресурсов. Движок умеет их трекать и подчёркнуто пересчитает всё сразу после активации окна, но если эту фазу пропустить, он ничего не сделает и в результате можно обнаружить, что игра не видит файлы по их resx или uid. Это в том числе касается свежескаченных репозиториев. Без открытия проекта в редакторе Godot мы рискуем запустить проект без его изображений, а иногда и без сцен.
Промежуточное заключение
Репозиторий по состоянию на конец этой главы лежит здесь.
Сегодня обошлись без фундаменталочки и срывов покровов, но зато теперь у нас есть репозиторий с базовой структурой проекта, которую я могу с небольшими вариантами запихивать в каждую статью по Godot. Разумеется, её можно заюзать в петах, в проде и далее везде.
Я дал три полноценных варианта развёртывания MyScene, плюс ещё один полу-вариант через консольные аргументы. Какой из них выбрать — зависит от личных предпочтений и задач. Конкретно я пишу всё из кода с применением консольных аргументов, поэтому большинство моих проектов содержит только одну сцену типа CmdlineUserArgs.tscn. Подчеркну, что я не усердствую по части накручивания списка аргументов, так как любую сложную параметризацию можно перенести в UI и далее решать сложные проблемы развитыми средствами.
Следует заметить, что всё описанное в главе можно реализовать на чистом C#. Местами будет даже короче, чем на F#, но я понятия не имею, как будет выглядеть DSL, и будет ли по итогу с этого какой-нибудь профит. К тому же компоновка кода на C#, на мой взгляд, носит несколько противоестественный характер. Если F#-проект больше напоминает книгу с поправкой на современные технологии, то C#-проект выглядит как некий каталог, к которому просто необходимо прикрутить ещё один каталогизатор. Редактор Godot с этой ролью до поры справляется. Таким образом, отказываясь от Godot-редактора на F#, мы не теряем ничего, а вот на C# потерю придётся как-то восполнять.
В следующей главе мы вернёмся к тайловым мирам и поговорим о том, как можно строить приложения на F# со своей региональной спецификой.
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.
