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:
Godot – Drawing Without Rules
Rectangular tile worlds
Hexagonal Tile Worlds
Tile lighting without pain – not quite in the loop, but close.
The articles use GDScript
but 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 Godot
There 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. With
since 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 Abs
since the function copes with all the diversity abs
available 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 someA
satisfying 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 round
since 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 dotnet
but 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.None
which 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 None
systems 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.UMX
where 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 UMX
then it is worth talking about the concept Single Case Discriminated Union
in 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 AbsolutePath
which 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 RenderingServer
which 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 Rid
Since 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 NodePath
and 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 Node2D
which 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 Node
although 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