A 60-Year-Old Prisoner and a Lab Rat. F# on Godot. Part 1. Meeting the Framework

Last time I mainly talked about the difficulties that arise when trying to combine F# and Godot. This was a forced measure, since we were primarily interested in the “standard” behavior in case the non-standard and convenient for some reason did not work. You could say that we learned to fall without serious consequences before we learned to perform throws and painful holds. The right move if we do not want to disable most of the group in a couple of classes, but still, this is not what we came to the section for. Now it's time to move on to routine, and then to more aggressive techniques.

Background of the story

A few weeks ago, right in the middle of the summer heat, I caught the corona again, infected everyone around me, and on the way out (after 4 days of being face down on the floor) I had an acute attack of initiative and efficiency. During it, I came across this series of articles:

  1. Godot – Drawing Without Rules

  2. Rectangular tile worlds

  3. Hexagonal Tile Worlds

  4. Tile lighting without pain – not quite in the loop, but close.

The articles use GDScriptbut I rewrote everything in F#, extracted the necessary pieces, cleaned it up, simplified it and used it as an example. Fortunately, the material is good, there is a lot of it, and when applied to F#, it generates quite a lot of interesting and at the same time typical cases that I want to pay attention to. On the other hand, the resulting subject area almost does not interact with the user, takes very little from the engine and has states that do not need to be cleaned up. It turns out to be a good equivalent of an educational console project with a minimal share of magic.

The first part of the tile cycle briefly explains the drawing mechanism through RenderingServer (then it was called VisualServer). It is very likely that the same mechanism is used to draw standard nodes. In any case, the categorical apparatus RenderingServer is very similar to the general Godot one, so the brain easily projects one model to another. The problem is that the API of this type is written in such an old-school and machine-oriented way that an alternative set of abstractions on top of it would be needed that are close in meaning to the existing ones. You need a good reason to prefer RenderingServer standard nodes. However, this is our foundation and it is a good entry point for those who need to draw a lot, more finely and efficiently than standard nodes.

Next up is an article on square grids, which are drawn using the mechanism mentioned above. And while I'm more interested in hexagonal and point-to-point maps, I find the second part to be the most substantial, so all planned writing will be about it or even more primitive examples. The remaining hexes and lighting either don't bring any fundamentally new examples, or are too far away for me to get to them in this phase of publications.

Due to lack of space, I will not duplicate @Goerging's text in any way or go into technical details of the algorithms unless they are related to the language or the engine. Consider that I brazenly stole the entire lore from another author in order to focus on the details that interest me, of which there are a lot.

The larger the project I consider in my article, the more techniques I want to use. But usually they either need to be abandoned or commented on somehow, otherwise only a very limited number of people will be able to understand the cause-and-effect relationships. However, I see Godot as a very attractive niche for F#, so this time I tried to build the entire cycle from the position of an F# newbie with an unknown level of engine proficiency. It turned out slow in places, especially in parts 2 and 3, but I wanted to drag total amateurs from the plough to the nuclear bomb in absentia without serious shocks.

Laboratory experiments

There is a story floating around the internet that F# is a language for mathematicians who solve mathematical problems. I don’t know where this idea comes from, but the community encounters apologists for this concept several times a year, and then shares the pearls of wisdom in specialized chats. It’s not that we don’t have highbrow mathematicians, but they are just as hard to reach as in other industrial languages. On the other hand, there can be significantly more mathematics in gamedev than in regular subject areas, so it’s worth paying attention to the available inventory.

As mentioned earlier, the Godot runtime feature deprived us of REPL-A (read-eval-print loop). Deprived, but, as it turned out, not entirely. Most of the mathematical apparatus in Godot, such as vectors, matrices, etc., do not require contacts with the engine core, which is why they remained available for work through REPL. We connect the necessary package, and we can test hypotheses:

#r "nuget: Godot.CSharp"
#r "nuget: Hedgehog"

open Godot
open Hedgehog

Property.check ^ property {
    let! point, offsetBefore, offsetAfter =
        [-100f..100f]
        |> Gen.item
        |> Gen.tuple
        |> Gen.map ^ fun (x, y) -> Vector2(x, y)
        |> Gen.tuple3
    let! scale = 
        Range.constant 0.01f 100f
        |> Gen.single
        |> Gen.tuple
        |> Gen.map ^ fun (x, y) -> Vector2(x, y)
    
    let transform = 
        Transform2D
            .Identity
            .Translated(offsetBefore)
            .Scaled(scale)
            .Translated(offsetAfter)
    
    let projected = transform * point
    let reversed = transform.AffineInverse() * projected
    counterexample $"{point} -> {projected} -> {reversed}"
    let delta = (reversed - point).Abs()
    return delta.X <= 0.01f && delta.Y <= 0.01f
}

It won't be possible to test graphics and UI this way, for them we need to somehow launch REPL inside the running engine.

Type extensions

In F# type extensions — a cool mechanism that allows you to supplement existing types with cosmetic methods and properties. I call them cosmetic because they can only do what can be done with a regular external function. No one will let you store data that is not originally provided by the type, since you cannot add fields, interface implementations, or constructors. You also will not get access to protected members. Inheritance is required for all this anyway.

Cosmetic members are a quick-fix helper that hides all the internal logistics behind a convenient façade. The syntax of extensions in F# is much simpler than in [<Extension>] from C# (although the latter can also be used). For example, the operation of discarding one of the components of a vector might look like this:

[<Auto>] // Автоматическое открытие при открытие родительского модуля или пространства.
module Auto // Обязательно нахождение в модуле

type Vector2 with // Синтаксис расширения типа
    member this.X_ = Vector2(this.X, 0f) // Ну или `this * Vector2.Right`
    member this._Y = Vector2(0f, this.Y) // `* Vector2.Down`

Extensions can contain a lot of things, but more often than not, they contain either small functions that clearly belong to a certain type, or functions that are suspended in the air, for which you need to create a module with an unobvious name:

type RenderingServer with
    static member canvasItemCreateIn parent =
        let res = RenderingServer.CanvasItemCreate()
        RenderingServer.CanvasItemSetParent(res, parent)
        res

Extensions are a very important feature when dealing with large foreign frameworks like WPF, AvaloniaUI or GodotThere is no need to talk about it in categories. будем использовать VS не будем. You will definitely (unless, of course, you are trying to find a reason for endless whining in the same company).

It would be better to take care of the convention of how these extensions will be placed in the project. In the conditional Utils everything won't fit, and you shouldn't underestimate the power of definitions in the narrowest possible scopes. It gets to the point that sometimes I really wish extensions could be written inside functions.

Initializing properties

By default, at the syntax level, the vast majority of declarations in F# imply immutability. This is a significant factor that affects the entire system, so sometimes we need to argue for mutability.

The biggest difference is felt at the micro level (which is not related to frameworks). In F#, mutable variables require a separate keyword:

let mutable a = 42

And further changes are carried out using a separate assignment “operator” <-which is specifically made different from the comparison:

// Присваивание
// : unit
a <- 32

// Проверка на эквивалентность
// : bool
a = 32

There are no direct analogues in F# i++, ++i, += etc., which, before the invention of ChatGPT, prevented the mechanical transfer of algorithms from other languages. However, the absence of operators has little effect on algorithms born in the field, since their niche is occupied by higher-level mechanics (we will partially touch on this in the following articles).

At the macro level, mutating data in F# is slightly different. To set properties and fields, an arrow is used:

let camera = new Camera2D()

camera.PositionSmoothingEnabled <- true
camera.PositionSmoothingSpeed <- 4f

We might have some problems with all sorts of things. +=but Godot is stuffed with methods like Node2D.Translate. And in principle, the more mutations through methods, the less difference between languages.

There is an exceptional case when assignment is implemented through the equal sign:

let camera = new Camera2D(
    PositionSmoothingEnabled = true
    , PositionSmoothingSpeed = 4f
)

Because of C#, this case is mistakenly associated with constructor initialization, but in fact, properties can be set whenever you work with methods (in the meaning of non-mutable functions) if they return a mutable object. It is enough to pass the method parameters, after which you can set the properties in the same place returned object:

match List.ofSeq cell with
| [:? Syntax.ParagraphBlock as singleParagraph]->
    singleParagraph.Convert(
        styleConfig
        , SizeFlagsStretchRatio = table.ColumnDefinitions.[index].WidthAsRatio
    )
| unexpected -> ...

This feature works in combination with the previous one and can produce funny effects. For example, in our code you can find this method:

type Vector2 with
    member this.With () = this

From the layman's point of view, this code is absurd, since instead of any editing we return the same object. The catch is that Vector2 is a structure, and when a structure goes through the mill of a method, a new instance comes out, but with the same data. Thanks to this, the code from the previous paragraph can be rewritten as follows:

type Vector2 with
    member this.X_ = this.With(Y = 0f)
    member this._Y = this.With(X = 0f)

We don't need to define the method parameters. Withsince the language offers a good alternative. There is only one danger here. It is quite possible that you will have to explain the structure of this hack to someone who accidentally got in. In my practice, a couple of times this unexpectedly led to the most severe multi-step stupidity, so I do not recommend riveting With() for each structure you have. In addition, for structures that are used as static configs, it is much more rational to duplicate static properties using the same-name method:

type Transform2D with
    static member NewIdentity () =
        Transform2D.Identity

let shifted = Transform2D.NewIdentity(Origin = 100f * Vector2.One)

Statically Resolved Type Parameters

Statically Resolved Type Parameters (or in short SRTP) — in the context of the current topic, it can be interpreted as dactyping at the compilation stage. The syntax of this thing is very complex in some cases, so it is better to study it separately. You implicitly encounter it every time you use operators (+), (-), (*) etc., so you can look at their signature. With SRTP, you can write things of a more practical nature. For example, thanks to SRTP, we do not need any System.Math with a thousand method overloads Abssince the function copes with all the diversity absavailable in the global scope:

abs -42 // 42
abs -42f // 42f
abs -42I // 42I

abs works via SRTP. The function expects an object of type 'a with static method Abs with a signature of the type : 'a -> 'a. This method is present in all signed numeric primitives. The function is defined approximately as follows:

let inline abs (a : 'a when 'a : (static member Abs : 'a -> 'a)) =
    'a.Abs a

In practical terms, this means that in abs can be transferred any someAsatisfying the condition, and not just a number. For clarity, below is an example of a syntactically correct type with absolutely incorrect semantics:

type AbsFriendly = {
    Value : string
}
    with
    static member Abs absFriendly = {
        Value = $"Абузим abs: {absFriendly.Value}"
    }
// type AbsFriendly =
//   { Value: string }
//   static member Abs: absFriendly: AbsFriendly -> AbsFriendly

let before = { AbsFriendly.Value = "Проверка" }
// val before: AbsFriendly = { Value = "Проверка" }

let after = abs before
// val after: AbsFriendly = { Value = "Абузим abs: Проверка" }

This is, of course, a caricature. In reality, such functions almost always pass only numeric types, less often custom combinations of numeric types. For example, when modeling board games, it is convenient to use non-trivial resource models (tokens, etc.) that can respond to sign, abs, ceil, truncate or roundsince this allows complex ones to be defined trivially inline-functions over unrelated types. Computer games are significantly behind board games in terms of game mechanics, but they are significantly superior in terms of linear algebra. Unfortunately, F# and Godot have problems with this point.

Godot has its own “standard” set of types for representing vectors and matrices, as well as accompanying types that partially support the behavior of vectors (e.g., colors). They have been tacked on operators and fluent syntax, but for some reason they forgot about static methods. SRTP sees operators, they are still standard for everything dotnetbut it cannot think up static methods based on instance methods. It is not possible to fix this problem using type extensions, since SRTP only looks at the members that were initially defined in the type. This will be fixed in some future version of F#, but for now you either have to sit in the same boat with other godot developers, or define your functions specifically for vectors:

let inline abs' (vec : 'vec when 'vec : (member Abs : unit -> 'vec)) =
    vec.Abs()

abs' -Vector2.One // (1f, 1f)
abs' Vector2I.Up // (0f, 1f)

After all, vectors have their own operations that don't conflict with operations on numbers:

let inline distance (a : 'vec when 'vec : (member DistanceTo : 'vec -> float32)) (b : 'vec) =
    a.DistanceTo b

distance Vector2.One Vector2.Zero // 1.414213538f
distance Vector3.One Vector3.Zero // 1.732050776f
distance Vector4.One Vector4.Zero // 2.0f

// Универсальность типов наследуется новыми функциями за счёт инлайна.
let inline nearestTo me vecs =
    vecs
    |> Seq.minBy ^ distance me

Marked numbers

Units of Measure (or simply Measures) — units of measurement. Initially, this was a simple idea of ​​a very practical nature – to give developers the ability to label the numbers they use with units of measurement:

type [<Measure>] m
type [<Measure>] s
type [<Measure>] g

// 127_000f<g>
let weight = (105f + 10f + 12f) * 1_000f<g>

// 0.694444418f<m/s>
let speed = 25_000f<m> / 36_000f<s>

// 88194.4375f: float32<g*m/s>
let momentum = weight * speed

Such numbers can be manipulated in a natural manner, and the unit markers will behave as expected. They will also block obviously incorrect calculations, such as adding meters to seconds or square meters to cubic meters. The restriction will apply to functions and properties, up to the point that marked numbers cannot be put into regular slots and vice versa:

let f (x : float32) = ...

// Ошибка компиляции:
f momentum

// Корректные варианты:
f (momentum / 1f<g * m / s>)
f (momentum * 1f<s / g / m>)

let g (x : float32<m>) = ...

// Ошибка компиляции:
g 10

// Корректный вариант:
g 10<m>

All this magic only exists at compile time, so the units have no effect on performance. The problem is that there is no way to find out the units of a boxed (reduced to object) numbers, since from the runtime point of view, float32 And float32<m> will be identical. The case has the same problem Option.Nonewhich at runtime is equal to null. Because of this irreversible degradation, neither the unit of measurement nor Option cannot be fully used in channels transmitting obj. DataTemplate in WPF do not respond to Nonesystems in ECS ignore messages, DI– containers registered values, etc. In such environments, you need to be on the safe side and use these values ​​only within types with clear identification.

Games come in all shapes and sizes, but my games don't need the International SI yet. I use measures in business logic on short intervals to mark numeric values ​​that I don't want to inadvertently mix up. This is rare, and usually involves really nasty calculations that slip out of my mind. They are usually in different contexts, so there are no unions like <m / s> or <g * m> I either don't have any at all, or in rare cases they are tied to something <localTick>which does not coincide with the usual delta. But if we talk about the mathematical apparatus of graphics, then I would not be against labeling vectors and matrices from the point of view of their area of ​​application, like <globalPixel>, <localPixel>, <tile> etc. Unfortunately, at the moment the only area where measures can be attached without any fuss, these are degree measures that are expressed as numbers. This means that they can be easily attributed both in SI (radians or degrees) and by area of ​​application (angle on the field or angle on the screen). You can write your own types with support for units of measurement, which allows you to count on a kosher duplicate of Godot vectors in the long term, which will support all the above features (including SRTP). However, you cannot attach units of measurement to other people's types.

Okay, to be precise, it is possible, but with the loss of all the binding of operators, functions, etc. The methodology can be found in FSharp.UMXwhere they learned to attach units of measurement to strings using unsafe hacks, Guid-am, dates, etc. The problem is that the line marked with some <userId>is no longer a string from the language's point of view. To find out its length or concatenate it with another string, you have to turn it back into a regular string. In the case of UMX, this is an acceptable sacrifice, since the package's job is to isolate identifiers of different tables and entities, not to perform intensive mathematical operations. In our case, the contents of the black boxes are as important as their isolation by type, so UMX-We are not satisfied with the scenario.

Single-Case Labeled Unions

Since we've touched on the topic UMXthen it is worth talking about the concept Single Case Discriminated Unionin short SCDU. This is another architectural technique that is used when it is necessary to isolate some primitive of the subject area from the rest. It is heavier than measures, but is more common, since it can be applied to non-numeric types and is generally aimed at isolating logic that differs significantly from the behavior of the base type.

For example, you may have a system that works with relative paths in the preparatory stage and absolute paths in the final stage. To eliminate the risk of accidentally mixing them, you can create two types: RelativePath And AbsolutePathwhich will contain the same strings, but they cannot be put into functions intended for a different type:

type RelativePath =
    | RelativePath of string

let handleRelativePath (RelativePath path) =
    // path : string
    ...

DUs can be very different, so their representation in memory has the greatest variability. Guessing about this is pointless, it is better to check the real state of affairs on the spot. For example, a structural single-place single-case DU is optimized by the compiler to a state close to its contents. At the same time, even after boxing, an instance of this type will not degenerate into string:

[<Struct>]
type FastRelativePath =
    | FastRelativePath of string

SCDU can save us from incorrect transmission of identifiers. To do this, it is enough to use at the business logic level SCDU instead of naked Guid, int And string:

type MyEntityId =
    | MyEntityId of System.Guid

At this point, the problem of broadcasting various queries arises, which tends to break on such things. To prevent this from happening, the above-mentioned was written specifically for identifiers FSharp.UMX. Personally, I didn't like this package, but I can't help but point it out. I usually don't write queries over domain types, so I prefer to use a regular record for identifiers. It doesn't make any significant difference, so by inertia this thing is also called SCDU in the team, or at most an SCDU record.

RenderingServer works with objects according to their Rid (Resource ID). At the same time, there are clearly non-intersecting varieties of objects, the identifiers of which could be distributed as follows:

type [<RequireQualifiedAccess>] CanvasItemId = { AsRid : Rid }
type [<RequireQualifiedAccess>] CameraId = { AsRid : Rid }
type [<RequireQualifiedAccess>] ViewportId = { AsRid : Rid }

Ideally, a projection can be generated RenderingServerwhich will work with custom identifiers. However, these are just checkers, the main thing is to translate domain types and functions to them. That is, our DrawMap, FillCell etc. accept identification records, and in RenderingServer transmit the usual RidSince most errors with identifiers occur at the moment of their transmission over long distances, protection should be placed not in local functions, but in communications.

If the value in SCDU must strictly comply with some law, then its “constructor” can be locked together with the content. F# does not recognize separate locking, so deprivate the data yourself:

module DU =
    type Main = 
        private
        | Case of string

        with
        member this.AsString = 
            match this with
            | Case str -> str

    let tryCreate str =
        if (str : string).StartsWith "TILE:"
        then Some ^ Case str
        else None

let (|DU|) (du : DU.Main) = du.AsString

module Record =
    type Main = 
        private { raw : string }
        with
        member this.AsString = this.raw

    let tryCreate str =
        if (str : string).StartsWith "TILE:"
        then Ok ^ Case str
        else Error """Must start with "TILE:"."""

let (|Record|) (record : Record.Main) = record.AsString

Define factory via option, Result or an exception is the developer's choice, but it should still be made taking into account the scope of use.

In protected form, SCDUs begin to merge with the concept of categories and zavtypes. In the minds of some, these terms are completely identified, which in my opinion is a gross error. The range of options from numbers to protected SCDUs is discrete, but the frequency of the options in it is large enough to select what is really necessary. The worst thing that can be done in this place is to truncate all the diversity to two or three patterns.

Godot is no stranger to the concept of small types. The most common is NodePathand if it were written in F#, it would most likely use SCDU. A wrapper type over a string with a small set of domain functions. This type also allows the Godot editor to recognize the field as a node address:

type NodePath = {
    AsString : string
}
    with
    member this.Names : string list = ...
    member this.SubNames : string list = ...
    member this.IsEmpty : bool = ...
    ...

When working with SCDUs, it is important to understand the scope of a particular type. SCDUs can act as explicit markers on the boundary between a foreign and native context, which implies their protrusion. But for protrusion to occur, they must be present somewhere and not present somewhere. In addition, even within a single context, SCDUs may have a limited scope of application. For example, a reading model may be completely open and expressed in regular types, while a writing model (more often Command) will require protected SCDUs.

There are no clear criteria in this matter, at least for abstract situations. But the closer you get to the specifics, the less space you have for ranting.

Overloading in the wrong place is a problem

Godot's API is written with C# in mind, which is why some entities take on forms that are not determined by their content. For example, overloads are extremely common in C#, while F# is very cool about them. Curried functions do not have them at all, which is explained by the current level of type inference. Overloads are allowed in methods, but the methods themselves are not available everywhere. Therefore, the rule “one name – one signature” is generally followed.

The reason for this dislike is quite simple: overloads do not help type inference in any way. A typical situation is when a module with intensive use of overloads contains more explicit type declarations than the rest of the project, and the supposed improvement in readability is never discovered. Anticipating this, I prefer to build my own APIs on top of existing ones, and performance considerations do not play a role, since if necessary inline will make this dozen of features absolutely free.

There are cases like DSL where method overloads are justified. And there are cases where they simply interfere with work. For example, in the type Node there are two methods GetNode:

GetNode<'a> : NodePath -> 'a
GetNode : NodePath -> Node

In F#, we rarely explicitly type generics; the compiler does it for us. Because of this, visually, the application of these methods should look identical. The generic version is obviously reducible to the non-generic version, so the latter could be discarded. But in fact, it is the non-generic version that will take precedence, so the following code will stop compilation:

let draw (drawer : Node2D) = ()
draw ^ this.GetNode pathToDrawer

From the compiler's point of view, we are trying to shove Node instead of Node2Dwhich is certainly a mistake. For the compiler to understand us, we need to write at least like this:

draw ^ this.GetNode<_> pathToDrawer

This construction only occurs because the method has overloading. If you define the following extension yourself, calling it will not require any angle brackets:

type Node with
    member this.getNode path = this.GetNode<_>(path)

draw ^ this.getNode pathToDrawer

The new feature allows you to further correct an obvious flaw in Godot. For some reason, the original GetNode<'a> doesn't count 'a heir Nodealthough the device AddChild and not generalized GetNode says the opposite. It is unlikely that we will specifically try to take through GetNode something else, it is more likely that such a call will arise during refactoring, since the type is often inferred only from the context. The compiler will be able to point out the erroneous request if it knows about the corresponding restrictions:

type Node with
    member this.getNode path : #Node = this.GetNode<_>(path)

Unlike C#, we have op_Implicit works with a creak. Usually we welcome this, but in this particular case NodePath This moment is much more often a hindrance. Therefore, it makes sense to refuse NodePath as the default parameter and replace it with a string. It is better to leave the path version, but under a different name:

type Node with
    member this.getNodeByPath path : #Node = this.GetNode<_>(path)
    member this.getNode path : #Node = 
        use path = new NodePath(path)
        this.GetNode<_>(path)

Thus, for known paths we will use a string. And in more complex scenarios, where there is a need for multi-step transmission (infrastructure code), we will move to NodePath.

Interim conclusion

For comfortable work, you should neglect the standard Godot API and actively duplicate it. The problem is not in a specific engine, but in the general ideology of C#, so the conclusion is true for most frameworks. Contrary to the intentions of newbies, you will not get any practical benefit from such a primitive unity with the rest of the dotnet community.

Further practical steps to get used to the engine come up against the need to deal with some “fundamental” matters for F#, which we will do in the next part.

Author of the article @kleidemos


UFO arrived and left a promo code here for our blog readers:
— 15% on any VDS order (except for the Progrev tariff) — HABRFIRSTVDS

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *