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

C#-еры то ли обитают в другом мире, то ли как-то пропустили этот момент, поэтому систематически пишут код в обратном направлении — от данных к процессам. Первое время я думал о Godot в том же ключе, но чем больше ковырял <Prefix>Server-ы (там их много, VisualServer — это лишь один из подобных), тем больше у меня складывалось ощущение, что где-то в недрах его команды сидит вменяемый ФП-программист. Уж слишком сильно некоторые решения напоминали наши. К сожалению, я ещё многое не расковырял из того, что нужно расковырять для потенциального разрыва с дефолтным API. Поэтому в рамках текущего цикла мы не будем избавляться от ООП-нагромождений. Вместо этого мы нагородим новых, но так, чтобы нам было удобно работать в ФП-парадигме.

Оглавление

Object expressions

Сущности, которые создаются через API RenderingServer, не контролируются сборщиком мусора (как в случае с соответствующими нодами), так как являются неуправляемыми ресурсами. Их надо освобождать руками или в полуавтоматическом режиме через уподобление System.IDisposable. Для этого в прошлой главе в расширении типа CanvasItemId был добавлен метод AsDisposable:

type CanvasItemId with
    member this.Free () = RenderingServer.FreeRid this.AsRid
    member this.AsDisposable () = IDisposable.create this.Free

IDisposable.create отсутствует в стандартной библиотеке, но по смыслу он должен иметь сигнатуру (unit -> unit) -> System.IDisposable. То есть мы берём функцию и создаём объект, который её вызывает при своём высвобождении. Для этого можно было бы завести отдельный тип:

module IDisposable =
    type Main (action) =
        interface System.IDisposable with
            override this.Dispose () = action ()

    let create action : System.IDisposable =
        Main action

Однако для столь простых сценариев в F# есть специальный синтаксис object expressions (объектные выражения). При помощи него код выше можно переписать так:

module IDisposable =
    let create action = {
        new System.IDisposable with
            override _.Dispose () = action ()
    }

Под капотом F# создаст тип, аналогичный Main, но у него будет страшное машинное название, которое будет мелькать при рефлексии, в дебаге и логах. Оно позволит установить происхождение экземпляра, когда что-то пойдёт не так, но его нельзя будет упоминать в коде, так как F# будет всячески отрицать существование этого типа. Зацепиться за такой тип в дальнейшем не получится. От него нельзя наследоваться, его нельзя «типизировано» засунуть в оболочку, к нему нельзя добавить расширение. Из всего этого следует, что object expressions нужно применять только там, где нас интересует исключительно исполнение контракта, а не его исполнитель.

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

module IDisposable =
    let create action = 
        let mutable disposed = false
        {
            new System.IDisposable with
                override _.Dispose () =
                    if not disposed then
                        action ()
                        disposed <- true
        }

Заметьте, что CanvasItemId иммутабелен, но результат CanvasItemId.AsDisposable() уже нет. У него есть состояние, и оно, как и сам объект, появляется только тогда, когда мы вызываем метод AsDisposable. В зависимости от выбранного жизненного цикла это может работать как в плюс, так и в минус. Если мы справимся с освобождением ресурсов при помощи «обычного» CanvasItemId.Free(), то нам не понадобится AsDisposable(), а значит, и его состояние. Мутабельный флаг — не абы какая нагрузка, но речь может идти и о более крупном объекте. С другой стороны, приобретя копию System.IDisposable, мы можем оказаться перед необходимостью контролировать её единственность.

Объектные выражения работают не только с интерфейсами, но и с обычными типами. Более того, их можно скрещивать в рамках одного выражения. Вот так может выглядеть System.IDisposable-объект с финализатором:

module IDisposable =
    let create action =
        let mutable disposed = false
        let dispose () =
            if not disposed then
                action ()
                disposed <- true
        {
            new System.Object() with
                override _.Finalize () = dispose ()
            interface System.IDisposable with
                override _.Dispose () = dispose ()
        }

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

Контакт с Godot

В нодах Godot есть метод _Process : (delta : float) -> unit, который вызывается на каждом кадре. Он ощущается как реакция на событие (в широком смысле), которое в Godot обычно моделируется при помощи сигналов, но такого сигнала в системе не существует. Вместо этого движок самостоятельно отслеживает ноды с таким методом и «вручную» запихивает в них текущую дельту. Единственный способ подписаться на это событие — это переопределить _Process в своей ноде и расположить её где-то в инстанцированном древе. Этот тезис справедлив ещё для нескольких методов-событий типа _PhysicsProcess, _Notification, _Input и т. д., общее место которых — они вызываются достаточно часто, чтобы сигналы не смогли пройти требования производительности.

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

main.AddChild {
    new Node() with
        override _._Process delta =
            ...
}

Но мы сможем добиться схожего поведения, если нарастим инфраструктуру. Идея в том, чтобы заранее определить ноды с override-ами соответствующих методов на C#. Генератор кода их подхватит и нагенерит нужный интероп. Если F#-наследники этих нод переопределят ранее переопределённые методы ещё раз, нового кодогена не будет, но старого нам будет достаточно.

Для всего этого нам придётся перейти на схему из трёх проектов, два из которых будут на C#.

Структура решения, которая предлагалась в An incursion under C#, с другими C#-проектами не особо дружит, так что вам придётся её обновить. Апдейт есть в небольшой статье-дополнении.

  • MyProject.Utils — C#-проект на Godot.SDK. Идентичен по пакетам проекту MyProject, за исключением того, что ни от кого больше не зависит.

  • MyProject.Core — F#-проект, который уже описывался ранее. Вся разница в том, что он теперь ссылается на MyProject.Utils.

  • MyProject — исходный C#-проект. Всё то же самое, что и раньше, но добавлена ссылка на MyProject.Utils, которая должна подтягиваться автоматом через зависимость от Core, но почему-то у меня в Godot этот автомат работает через раз.

Далее в Utils создаём папку Implements, а в ней комплект классов вида:

namespace Godot.Implements
{
    public partial class Implement_Process : Godot.Node
    {
        public Implement_Process() { }
        public override void _Process(double delta) => base._Process(delta);
    }
}

Один метод — один класс.

С этого момента мы можем наследоваться от Godot.Implements.Implement_Process в object expressions и движок подхватит наш override:

main.AddChild ^ { 
    new Godot.Implements.Implement_Process() with
        override _._Process delta =
            ...
}

Однако в нодах Godot много методов, и можно быть уверенным, иногда мы неосознанно будем смешивать методы не с теми классами. Поэтому лучше не рисковать и спрятать всё в хелперы с гарантированно верным override:

module GD =
    module Implements =
        let _process action = {
            new Godot.Implements.Implement_Process() with
                override this._Process delta = action this delta
        }

С помощью этой штуки процесс смены фреймов в Sprite2D можно будет описать вот так:

let mutable frameProgress = 0f

do sprite.AddChild ^ GD.Implements._process ^ fun _ delta ->
    frameProgress <- frameProgress + 10f * float32 delta
    if frameProgress >= 1f then
        sprite.Frame <- (sprite.Frame + int frameProgress) % 50
        frameProgress <- frameProgress - truncate frameProgress

То же самое касается обработки _Input. Следующим образом может выглядеть переход к полному экрану по F11 (да, как в браузере):

type Ready (main : Node2D) =
    do  let window = main.GetWindow()
        let mutable lastWindowMode = window.Mode

        main.AddChild ^ GD.Implements._input ^ fun _ ev ->
            match ev with
            | :? InputEventKey as ev ->
                if ev.Keycode = Key.F11 && not ev.Pressed then
                    window.Mode <-
                        if window.Mode = Window.ModeEnum.Fullscreen
                        then lastWindowMode
                        else
                            lastWindowMode <- window.Mode
                            Window.ModeEnum.Fullscreen
            | _ -> ()

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

Самоосознание

При реализации _Process мы передали в action не только дельту, но и this:

override this._Process delta = action this delta

Это крайне важное дополнение, так как через this можно получить доступ к дереву нод и главное, управлять жизненным циклом action. Например, вот так можно реализовать появление ноды через модуляцию:

let mutable showProgress = 0f
label.AddChild ^ GD.Implements._process ^ fun mi delta ->
    if showProgress > 1f then
        mi.QueueFree()
    else
        showProgress <- showProgress + 0.8f * float32 delta
        label.Modulate <- Colors.Transparent.Lerp(Colors.White, showProgress)

Когда акт появления завершится, нода-процесс, ответственная за управление прозрачностью, самоудалится.

Удалять можно и нечто большее. Например, в следующем примере мы запускаем в полёт flyLabel : Label с количеством новых миплов на планете. Он поднимется на 120 пикселей вверх и одновременно станет полностью прозрачным, после чего будет удалён вместе с внедрённым в него _process:

let flyLabel = new Label(
    Text = $"+%i{growth}"
)
growthSpawnPoint.AddChild flyLabel
let mutable flyProgress = 0f
flyLabel.AddChild ^ GD.Implements._process ^ fun _ delta ->
    if flyProgress > 1f then
        flyLabel.QueueFree()
    else
        flyProgress <- flyProgress + 0.4f * float32 delta
        flyLabel.Position <- 120f * Vector2.Up * flyProgress
        flyLabel.Modulate <- Colors.Green.Lerp(Colors.Transparent, flyProgress)

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

// Важно, что процесс прогресс выставлен в 1f, а не в 0f.
let mutable blinkProgress = 1f
let blinkProcess = GD.Implements._process ^ fun mi delta ->
    if blinkProgress < 1f then
        blinkProgress <- blinkProgress + 2f * float32 delta
        populationLabel.Modulate <- Colors.Green.Lerp(Colors.White, blinkProgress)
    else
        mi.SetProcess false
populationLabel.AddChild blinkProcess

Движок игнорирует _Process приостановленных объектов (.IsProcessing() = false), пока кто-то не запустит их снова:

blinkProcess.SetProcess true

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

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

Так в примере выше blinkProgress сразу находится в завершённом состоянии (1f), поэтому на первом же фрейме процесс остановит сам себя. Остановка будет повторяться при всех последующих вольных или невольных попытках запуска. Единственный способ действительно запустить процесс — присвоить blinkProgress значение, меньшее 1f (обычно 0f), и вызвать blinkProcess.SetProcess true. Этот спавн из завершённого состояния не соответствует нашим изначальным установкам, но для меня он давно стал системой и в данный момент не вызывает затруднений.

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

Ready

«Хак» с GD.Implements позволяет избавиться от большинства override-ов, но не от _Ready. С ним данный трюк не пройдёт, так как обработчики Children-ов вызываются до родительских:

// Псевдокод.
let rec ready (node : Node) =
    for child in node.GetChildren() do
        ready child
    node._Ready()

То есть находясь в _Ready потомка, мы не можем нормально взаимодействовать с предком, так как тот ещё не был инициализирован. Движок об этом знает и поэтому в его недрах есть аналог флага init из первичных конструкторов F#. Благодаря ему некоторая часть операций с предком будет заблокирована до окончания его _Ready.

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

and Main () as this =
    inherit Node2D()

    let mutable ready = None

    do  this.add_Ready (fun () -> ready <- Some ^ Ready this)

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

ReadyGroups

override-ов больше нет, но наследование осталось. Оно необходимо нам для связки .tscn с его F#-типом. Однако можно заметить, что мы вынесли все операции в область первичного конструктора, который представляет собой лишь функцию с некоторыми синтаксическими особенностями. Здесь встаёт вопрос, а нужны ли нам эти особенности при описании типа Main? И вообще, нужен ли нам сам тип Main (Ready оставляем), если всё его содержимое можно выразить одной лямбдой?

Оба раза я склонен отвечать «Нет». Мы прекрасно обойдёмся и без этого типа, если сможем объяснить движку, какую функцию надо вызывать при запуске сцены. То есть нам нужен примитивный маппинг между сценами и их инициализаторами, а не возня с наследованием. Я покажу свой вариант маппинга, но подчеркну, что он далеко не единственный.

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

module ReadyGroups

type EventArgs = {
    Groups : string list
    Source : Godot.Node
}

let private event = Event<EventArgs>()

let publicate node groups =
    event.Trigger {
        Groups = List.ofSeq groups
        Source = node
    }

let subscribe = event.Publish

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

publicate нужен в C#-типе ReadyGroups, который надо определить в исходном (не .Utils!) проекте:

// Учтите, что этот атрибут игнорируется, если атрибутированный тип находится не в исходном C#-проекте.
[GlobalClass]
public partial class ReadyGroups : Godot.Node
{
    [Export]
    public Godot.Collections.Array<string> Groups { get; set; } = new Godot.Collections.Array<string>();
    public ReadyGroups() { }
    public override void _Ready()
    {
        Core.ReadyGroups.notify(this, this.Groups);
    }
}

ReadyGroups не имеет .tscn файла, но класс снабжён атрибутом GlobalClass, поэтому он будет виден в библиотеке доступных нод в редакторе Godot:

Так как коллекция Groups атрибутирована как Export, она тоже будет видна в редакторе:

Мы сможем заполнить её строковыми значениями, после чего в фазе _Ready эта коллекция вместе с самой нодой окажется в потоке сообщений ReadyGroups.event. На другом конце солюшена, в .Core-проекте мы сможем подхватить это событие из ReadyGroups.subscribe и среагировать должным образом. Например, если мы добавили ноду ReadyGroups с ключом MyScene в MyScene.tscn, то инициализировать её можно так:

module MyScene

type Ready (main : Node2D) =
    ...

let initialize () =
    ReadyGroups.subscribe.Add ^ fun args ->
        let isMyScene =
            args.Groups
            |> Seq.contains "MyScene"
        if isMyScene then
            // NB: Мы используем источник только для того,
            // чтобы найти нужную ноду из его окружения.
            let parent = args.Source.GetParent()
            parent.add_Ready(fun () ->
                dowcans parent
                |> Ready
                |> ignore
            )

Функцию MyScene.initialize надо вызвать один раз на стадии автозагрузки в EntryPoint. О том, что это такое мы говорили вот здесь. Это было давным-давно и тогда нам приходилось работать с C# наследниками, вследствие чего EntryPoint содержал больше логики, чем хотелось. Однако теперь, когда мы почти полностью избавились от C#, всё содержимое EntryPoint может уехать в благословенные земли F#:

public partial class EntryPoint : Godot.Node
{
    public override void _Ready()
    {
        Core.EntryPoint.initalize();
    }
}
module EntryPoint

let private mutable initialized = false

let initialize () =
    if not initialized then
        initialized <- true
        MyScene.initialize()

С этого момента все запуски MyScene.tscn будут подхватываться и доделываться типом Ready.

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

module EntryPoint

let readyGroups () =
    ReadyGroups.subscribe.Add ^ fun args ->
        let parent = args.Source.GetParent()
        for key in args.Groups do
            match key with
            | "MyScene" ->
                parent.add_Ready(fun () ->
                    downcast parent
                    |> MyScene.Ready
                    |> ignore
                )
            | unexpected ->
                GD.print $"unexpected ready key : %A{unexpected}"

let initialize () =
    if not initialized then
        initialized <- true
        readyGroups ()

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

Сила композиции

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

Раз уж мы начали снова разбивать инициализатор на составляющие, то впору задаться вопросом, а нужен ли нам в таких условиях тип Ready? Ответ сильно зависит от размера и назначения сцены. Очевидно, что тип, который можно заменить простенькой лямбдой, лучше выражать через лямбду. Также очевидно, что понятие «простенькая» имеет свойство растягиваться и это далеко не всегда плохо для дела. Думаю, что когда у такой-то лямбды появляется состояние, которым надо «внезапно» управлять снаружи, Ready становится необходимым явлением.

Наличие внешнего взаимодействия также может оправдывать создание своего типа ноды. Предположим, что Area2D сталкивается с неким телом, и нам надо его типизировано идентифицировать. Раньше у нас был некий тип Kit.Main, который можно было использовать для идентификации тела:

// Где-то в недрах `BodyEntered`.
match body with
// Мы вынуждены работать с неизвестно чем.
| :? Kit.Main as kit ->
    // И только здесь мы получаем доступ к ядру типа.
    kit.Ready
    |> Option.iter ^ fun kit ->
        match kit.Kind with
        | Kit.Medicine value -> health.Heal value
        | Kit.Antidote ilness -> effects.Cure illness
        kit.Disappear()
| _ ->  
    ()

Теперь Main отсутствует, у нас есть лишь «анонимный» RigidBody2D, в котором не за что зацепиться. Нам нужен якорь или, скорее, анкер, в качестве которого может выступить дочерняя нода:

type Ready (main : Node2D) as this =
    // Сразу закидываем якорь в предка.
    do main.AddChild ^ Anchor(this, Name = "Kit.Anchor")
    ...
and Anchor (ready) =
    inherit Node()

    member _.Ready = ready
// Фильтрация по типу встроена в метод.
body.tryGetNode "Kit.Anchor"
|> Option.iter ^ fun (p : Kit.Anchor) ->
    let kit = p.Ready
    match kit.Kind with
    | Kit.Medicine value -> health.Heal value
    | Kit.Antidote ilness -> effects.Cure illness
    kit.Disappear()

Обычно я не люблю совмещать разные роли в рамках одного типа, но если Ready больше нигде не используется, то было бы логично избавиться от лишнего типа Anchor и просто унаследовать Ready от Node:

module Kit

type Ready (main : Node2D) as this =
    inherit Node(Name = "Kit.Anchor")

    do main.AddChild this
// Фильтрация по типу встроена в метод.
body.tryGetNode "Kit.Anchor"
|> Option.iter ^ fun (kit : Kit.Ready) ->
    match kit.Kind with
    | Kit.Medicine value -> health.Heal value
    | Kit.Antidote ilness -> effects.Cure illness
    kit.Disappear()

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

Изъяны аутсорса

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

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

Звучит страшно, но всё проще, чем кажется. Например, у нас может быть тип Castle с методом Process, который должен вызываться строго до одноимённого метода наших Army, чтобы те учитывали все эффекты приобретённых технологий и ритуалов в процессе своего функционирования:

type Castle (...) =
    // NB: Это не метод ноды.
    member _.Process delta = ...

type Army (...) =
    // NB: И это не метод ноды.
    member _.Process delta = ...

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

GD.Implements._process ^ fun _ delta ->
    castle.Process delta
    for army in armies do
        army.Process delta

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


В Godot есть методы, которые невозможно делегировать наружу. Например, в типе Control есть метод HasPoint : Vector2 -> bool, который используется движком для определения принадлежности точки к конкретному контролу. Переопределив его, можно описать поведение контрола непрямоугольной формы. По понятным причинам такой метод невозможно передать в дочернюю ноду, так что нам придётся проводить цепочку наследования через C#.

Я предпочитаю держать базовый тип с пробросами в проекте <Name>.Utils, его наследник с логикой в <Name>.Core, и лишь в редких случаях, когда мне нужен GlobalClass и т. п., я закидываю ещё один тип в корневой C#-проект. Мне это не нравится, но я терплю, так как такое происходит редко и на удивление быстро, так что я просто не успеваю прочувствовать ситуацию.

Промежуточное заключение

Мы смогли разобраться с самой неприятной и трудоёмкой частью Godot. С C#. Причём сделали это с явным оверкиллом, так как получившийся результат по возможностям превосходит то, что предлагает Godot из коробки. Теперь, даже если кто-то и адаптирует Godot-генераторы кода под F#, я всё равно не буду их использовать. Ну или буду, но приблизительно также часто, как пишу IEnumerator-ы с нуля. Приспичило — написал. Для «остальных» 99% случаев хватит модуля (и билдера) Seq.


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


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

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