
Сегодня мы закончим ковырять порт тайловых миров на F#. Правда, в прошлый раз мы исчерпали запас того, что вообще можно было «портировать», поэтому переключим внимание на совершенно новые фичи. Все они касаются UI (в узком смысле), ну или, говоря терминами игровой индустрии, HUD (Heads-Up Display).
Мы займёмся панелями по краям экрана и выведем на них:
общую легенду;
мини-карту;
текущие координаты корабля и курсора;
стоимость пути или причину его отсутствия;
управление игровым полем.
Этого достаточно, чтобы мы успели ознакомиться с процессом построения деревьев в условиях, близких к боевым. Одновременно с этим мы не вышли за пределы файла в поисках различных утилитарных подпорок, которые реальный проект генерирует в ударных количествах. Всё уместилось в один файл.
Оглавление
Мелкие бытовые радости. Выпуск №2
Несколько глав назад я утверждал, что система позиционирования Control-ов в Godot заслуживает особого внимания. Время для детального разбора всё ещё не пришло, однако в этом проекте я опять столкнулся с ситуацией, когда Control.LayoutPreset перестал работать должным образом. Причины этого поведения мне не до конца ясны, поэтому свои гипотезы озвучивать я не буду и вместо этого всучу вот такой код:
type Control with member this.AnchorPoint with set value = this.AnchorLeft <- (value : Vector2).X this.AnchorRight <- value.X this.AnchorTop <- value.Y this.AnchorBottom <- value.Y [<Extension>] type ControlExts = [<Extension>] static member TopLefted (this : Control) = this.AnchorPoint <- Vector2.Zero this.GrowHorizontal <- Control.GrowDirection.End this.GrowVertical <- Control.GrowDirection.End this [<Extension>] static member TopRighted (this : Control) = this.AnchorPoint <- Vector2.Right this.GrowHorizontal <- Control.GrowDirection.Begin this.GrowVertical <- Control.GrowDirection.End this [<Extension>] static member BottomRighted (this : Control) = this.AnchorPoint <- Vector2.One this.GrowHorizontal <- Control.GrowDirection.Begin this.GrowVertical <- Control.GrowDirection.Begin this [<Extension>] static member BottomLefted (this : Control) = this.AnchorPoint <- Vector2.Down this.GrowHorizontal <- Control.GrowDirection.End this.GrowVertical <- Control.GrowDirection.Begin this
Эти методы позволяют на лету распихивать контролы по углам их родителя при условии, что тот не является контейнером. В нашем случае речь идёт о размещении PanelContainer внутри CanvasLayer.
Система тем контролов в Godot устроена радикально иначе, чем в привычных мне UI-фреймворках, поэтому обычные DSL для неё не подходят. Однако в этой сцене мы будем повторять только две операции, поэтому потенциальным многообразием можно пренебречь:
type PanelContainer with member this.PanelStylebox with set value = this.AddThemeStyleboxOverride("panel", value) type MarginContainer with member this.Margin with set value = [ "margin_left" "margin_top" "margin_right" "margin_bottom" ] |> Seq.iter ^ fun key -> this.AddThemeConstantOverride(key, value)
Выглядит мило, особенно в сравнении с оригинальным API, но закатывать такие DSL в nuget-пакеты я не рекомендую.
Сеттеры в условиях функций
Основной способ конфигурации наших нод — это сеттеры:
// Валидный код. FG.Label(Title = "Boo!")
Существенный недостаток сеттеров — это то, что они не дружат с обычными функциями, из-за чего нам приходится менять такой код:
let centeredLabel () = FG.Label(HorizontalAlignment = HorizontalAlignment.Center) // Невалидный код. centeredLabel(Text = "Boo!")
На такой:
let centeredLabel () = FG.Label(HorizontalAlignment = HorizontalAlignment.Center) let label = centeredLabel() label.Text <- "Boo!" label
Если ситуация повторяется чересчур часто, мы можем вернуться к методам следующим способом:
let centeredLabel = System.Func<_>(fun () -> FG.Label(HorizontalAlignment = HorizontalAlignment.Center)) centeredLabel.Invoke(Text = "Boo!")
Это очень экстравагантный способ, так как Func-и и прочие делегаты — это не самый желанный гость в F#. Если они перемежаются с обычными функциями, жизнь начинает напоминать беготню по дому, в котором все дверные проёмы находятся на разном расстоянии от пола и потолка. В лучшем случае спотыкаешься, в худшем — освещаешь себе дорогу искрами из глаз. Поэтому на статическом уровне мы всячески уклоняемся от делегатов и пишем полноценные статические типы:
type Labels = static member TopCenter () = FG.Label(HorizontalAlignment = HorizontalAlignment.Center) [ Labels.TopCenter(Title = "Boo!") ... ] |>
Досадно, что в телах функции или первичного конструктора завести новый тип невозможно:
[ // Выдуманный синтаксис. let Label = { new type member _.TopCenter () = FG.Label(HorizontalAlignment = HorizontalAlignment.Center) } Label.TopCenter(Title = "Boo!") ... ] |>
В качестве промежуточного решения я иногда группирую функи в анонимных рекордах:
[ let Label = {| TopCenter = System.Func<_>(fun () -> FG.Label( HorizontalAlignment = HorizontalAlignment.Center ) ) |} Label.TopCenter.Invoke(Title = "Boo!") ... ] |>
От Invoke это не избавляет, но сам контекст вызова работает этакой напоминалкой-предупреждением. По тем же соображениям опасные зоны в цехах, вроде нестандартных дверей или заканчивающегося пола, обклеивают полосатой лентой.
Экранирование HUD
Корабль, блоки и игровое поле получают команды при помощи мыши, курсор которой может заходить на UI-ные панели. В этом случае на него могут одновременно реагировать и контролы с этих самых панелей, и игровые компоненты. Выглядит это не очень эстетично и, что ещё хуже, приводит к дурацкой ситуации, когда нажатие на кнопку интерфейса попутно создаёт или удаляет блок препятствия на клетке под курсором. В моём любимом Old World (написан на Unity) аналогичная бага до сих пор не устранена до конца (а ведь нам уже шесть годиков), из чего можно предположить, что проблема в принципе неистребима.
Если бы мы работали с сигналами, то мы могли бы противопоставить «игру» и HUD, но мы обрабатываем нажатия внутри _Process, поэтому нам надо защититься от действий в области панелей в ручном режиме. Для наших задач достаточно того, чтобы панели поднимали некий флаг всякий раз, когда курсор оказывается над ними. В теории для этого можно использовать сигналы MouseEntered и MouseExited, но в Godot они работают не так, как в WPF. Если мы возьмём PanelContainer и положим в него Button, после чего подпишемся на обозначенные сигналы, мы словим забавный баг (или фичу):
let mutable ignoreMouse = false FG.Button( Text = "Press me" , Pressed'Handler = disposables.HandleSignal ^ fun _ () -> ... ) |> FG.MarginContainer( Margin = 10 ).Add'Child |> FG.PanelContainer( MouseEntered'Handler = disposables.HandleSignal ^ fun _ () -> ignoreMouse <- true , MouseExited'Handler = disposables.HandleSignal ^ fun _ () -> ignoreMouse <- false ).Add'Child
Дело в том, что панель будет исправно сообщать о пересечении своих границ курсором мыши, но под границами она будет воспринимать как внешний контур, так и контур кнопки. То есть в прямоугольнике панели будет вырезан подпрямоугольник кнопки, который с точки зрения движка не считается частью панели. За такое поведение ответственен Button. Его MouseFilter по умолчанию настроен так, что он блокирует передачу ивентов мыши в хозяина кнопки. Этот фильтр можно перебить руками или при помощи рекурсивного обхода (мы же на F#). Я пробовал и то, и то, но пока решил отказаться от обоих, так как следить за мышью оказалось проще, чем копаться в контролах. Мы будем транслировать Rect-ы панелей в некий агрегатор, который позднее сможет сообщить нам, находится ли курсор мыши над хоть какой-то панелью интерфейса или нет:
module HUD = module Stylebox = let root = FG.StyleBoxFlat( BgColor = Colors.Black.With(A = 0.65f) ) type Main (disposables) = let canvasLayer = FG.CanvasLayer(Disposables = disposables) let inUi = FG.Control(Parent = canvasLayer) let panelRects = System.Collections.Generic.Dictionary() member _.CanvasLayer = canvasLayer member _.CreatePanel () = let id = System.Guid.NewGuid() FG.PanelContainer( Parent = canvasLayer , ItemRectChanged'Handler = disposables.HandleSignal ^ fun panel () -> panelRects.[id] <- panel.GetGlobalRect() , Panel = Stylebox.root ) member _.IsMouseOver () = let mouse = inUi.GetGlobalMousePosition() panelRects.Values |> Seq.exists ^ fun p -> p.HasPoint mouse
HUD.Main содержит весь пользовательский интерфейс. Создавая панель через CreatePanel, мы автоматически размещаем её в нужном предке, стилизуем и включаем трансляцию её координат в агрегатор:
let hud = HUD.Main(disposables.CreateChild()) do main.AddChild hud.CanvasLayer ... do FG.VBoxContainer().Add'Children [ ... ] |> FG.MarginContainer(Margin = 10).Add'Child |> hud.CreatePanel().BottomLefted().AddChild
Дальше эта панель нас не интересует. Для фильтрации команд нам достаточно взаимодействовать с IsMouseOver:
do main.AddChild ^ GD.Implements._process ^ fun _ delta -> let delta = float32 delta use _ = spaceship.DeferRedraw() underMouse.Process () match motion with | Some motion -> motion.Process delta | None -> if not ^ hud.IsMouseOver() then if Input.IsActionJustPressed "mouse_left" && PathFinder.inMap gridSize underMouse.Cell && spaceship.Position <> underMouse.Cell then blocks.Change { Position = underMouse.Cell } |> Option.iter ^ fun p -> p.IsUnderMouse <- true path.InvalidatePathFinder gridSize blocks.Obstacles.Contains spaceship.Position match path.Path.Result with | Ok path when Input.IsActionJustPressed "mouse_right" -> beginMotion path | _ -> () spaceship.Process delta
Этой схемы нам хватит за глаза, но предупреждаю, что ItemRectChanged тоже работает не так, как нам бы хотелось по опыту WPF. Этот сигнал может не среагировать на изменения, которые породил родительский контрол (или Node2D). Например, возможна ситуация, когда Rect.Position (а за ним и End) изменился, а нам об этом никто не сказал. Авторы объясняют этот момент соображениями перфоманса, и их рассуждения звучат достаточно рационально.
Проблема и решение `PositionChanged` для тех, кому реально надо
Дело в том, что Rect.Size — это сфера ответственности Control и его наследников, поэтому он более-менее наблюдаем. А вот Rect.Position вычисляется на основе матрицы трансформации, которая досталась Control от его предка — CanvasItem. Это хорошо, потому что контролы можно легко подсаживать к Node2D, и они будут очень шустро перемещаться (радикально быстрее, чем в WPF). И это плохо, потому что CanvasItem ради такой скорости многим пожертвовал, и в это многое попал гипотетический сигнал PositionChanged. Его нет и не будет, по тем же соображениям, почему у нас нет сигнала для _Process и т. д.
Метод _PositionChanged нам также не дали. Официально нам предлагают решать эту проблему через нотификации. Любой CanvasItem можно настроить так, что он будет сообщать о своём CanvasItem.NotificationTransformChanged. Дальше при каждом сообщении надо будет забирать текущий GetGlobalRect() и закидывать его в агрегатор. Из-за ограничений генераторов C# нам придётся:
Отнаследоваться от
PanelContainerв<ProjectName>.Utils;Снова отнаследоваться от этого нового типа в
<ProjectName>.Core(возможно, черезobject expressions);Использовать этот второй тип в точке спавна;
И уже там воспользоваться этой «подпиской».
Немного муторно в первый раз, но вполне реализуемо при желании. В теории тем же путём мы можем сделать GD.Implements._positionChanged на базе Node2D.
С другой стороны, если Position так сопротивляется, возможно, нам стоит поступить иначе. Пару глав назад я упоминал, что в Godot отсутствует сигнал об удалении ноды в следствие вызова Free (или QueueFree). Это ограничение мы обошли при помощи CompositeDisposable. Вместо ручного высвобождения нод мы дёргаем disposables.Dispose, а уже он убивает ноды и вызывает код, эквивалентный реакции на несуществующий сигнал.
Проблему отсутствующего PositionChanged лучше всего решать по тем же лекалам. Во-первых, не надо двигать ноды руками (если нам нужен ивент), надо дёргать прокси-объект, который умеет дёргать ноды и потом сообщать об этом всем заинтересованным сторонам. Во-вторых, если позиция нашей ноды зависит от родителя, то лучше всего привязаться к изменению последнего напрямую. Пусть он сообщает об этом прокси-объекту, а тот ретранслирует сообщение дальше.
Мини-карта
В построении мини-карты с теми данными, что уже на руках, особых сложностей не возникает. По факту, мы дублируем код из PathPredictor.View, но используем немного иное API:
module Minimap = type Main (disposables, gridSize, blocks : Blocks.Main, predictor : PathPredictor.Model) = let image = Image.CreateEmpty( (gridSize : Vector2I).X, gridSize.Y, false, Image.Format.Rgbaf , Disposables = disposables ) let texture = ImageTexture.CreateFromImage( image , Disposables = disposables ) do let rec go () = invalidate predictor.Path disposables.Add ^ predictor.Changed.subscribe invalidate and invalidate path = image.Fill SceneColors.background for i in blocks.Obstacles do image.SetPixelv(i, SceneColors.block) match path.Result with | Ok ok -> ok.Costs |> Seq.iter ^ fun (p, _) -> image.SetPixelv(p, SceneColors.seen) ok.Core.Ready.Keys |> Seq.iter ^ fun p -> image.SetPixelv(p, SceneColors.explored) ok.Path |> Seq.iter ^ fun p -> image.SetPixelv(p, SceneColors.path) image.SetPixelv(path.Goal, SceneColors.goal) | Result.Error (PathPredictor.Error.Unreachable p) -> p.Core.Paths.Keys |> Seq.iter ^ fun p -> image.SetPixelv(p, SceneColors.seen) p.Core.Ready.Keys |> Seq.iter ^ fun p -> image.SetPixelv(p, SceneColors.explored) | Result.Error PathPredictor.Obstacle -> image.SetPixelv(path.Goal, SceneColors.unreachableGoal) | Result.Error _ -> () image.SetPixelv(path.Start, SceneColors.spaceship) texture.Update image go () member _.Texture = texture
Существование Motion на мини-карте не сказывается, потому что я к нему не привязывался. В основном из-за того, что не уверен в его необходимости. Но если бы я таки решился на это, то, скорее всего, начал бы с унификации раскраски поля. В текущих условиях напрашивается некий промежуточный узел, в котором хранятся данные по цветам каждого тайла. В этом случае PathPredictor.View.Surfaces.UnderGrid, Motion.View.Surfaces.UnderGrid и Minimap.Main.Texture станут проекциями одного и того же типа входных данных.
Размещение мини-карты выглядит так:
do FG.VBoxContainer().Add'Children [ asNode ^ Label.TopCenter.Invoke( Text = "Миникарта" , LabelSettings = FG.LabelSettings( FontColor = SceneColors.title ) ) FG.TextureRect( Texture = minimap.Texture , CustomMinimumSize = gridSize.AsVector2 * 16f , ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize , TextureFilter = CanvasItem.TextureFilterEnum.Nearest ) ] |> FG.MarginContainer(Margin = 10).Add'Child |> hud.CreatePanel().BottomLefted().AddChild
Здесь важно не забыть про CanvasItem.TextureFilterEnum.Nearest. Эта штука сохранит текстуру пиксельной, несмотря на её растягивание. В противном случае можно получить «мыльцо».
Также стоит отметить, что текстуры в Godot реактивны. Когда мы вызываем texture.Update image в недрах Minimap.Main, все потребители этой текстуры автоматически стягивают обновление. Это работает даже с RenderingServer-ом. Если мы разместим текстуру прямо на поле, нам не надо будет перерисовывать её на каждом фрейме. Движок сделает это за нас:
do let rootLayer = CanvasItemId.Create( Parent = main.CanvasItemId , Disposables = disposables , Transform = gridTransform ) [ motionLayers.UnderGrid pathView.Surfaces.UnderGrid gridView.Surface spaceship.Surfaces.Highlight motionLayers.OverGrid pathView.Surfaces.OverGrid underMouse.Surface figureLayers.Surface let minimapShow = CanvasItemId.Create( DefaultTextureFilter = RenderingServer.CanvasItemTextureFilter.Nearest ) grid.Area.Texture minimapShow minimap.Texture.TextureId Corner.TopLeft (Rect2I(0, -5, 5, 5)) minimapShow ] |> Seq.iter rootLayer.AddCanvasItem

Глубокий космос
При описании деревьев нод важно помнить, что почти всё, что мы пишем на F#, является выражением, а значит, создавать привязки можно в любом месте дерева. Отсюда проистекает максима: располагать привязки следует как можно глубже, так как чем уже скоуп, тем меньше шансов что-то сломать и ещё меньше шансов, что кто-то влезет в процесс со стороны.

В идеале должна соблюдаться формула «создали, применили, забыли». В пике она может приобретать бессловесные формы. Например, здесь я даже не потрудился придумать имя для строчки, состоящей из цветного квадрата и подписи:
do FG.VBoxContainer().Add'Children [ asNode ^ Label.TopCenter.Invoke( Text = "Легенда" , LabelSettings = FG.LabelSettings( FontColor = SceneColors.title ) ) let (!) color name = FG.HBoxContainer().Add'Children [ asNode ^ FG.ColorRect( CustomMinimumSize = Vector2.One * 24f , Color = color ) FG.Label( Text = name , VerticalAlignment = VerticalAlignment.Center ) ] !SceneColors.seen "Известна предварительная стоимость (Paths/Costs)" !SceneColors.explored "Получена итоговая стоимость и путь (Ready)" !SceneColors.path "Путь" !SceneColors.goal "Пункт назначения" !SceneColors.unreachableGoal "Недостижимая цель" !SceneColors.motion "Активированный путь (анимация)" !SceneColors.block "Препятствие (блок)" !SceneColors.spaceship "Космический корабль" ] |> FG.MarginContainer(Margin = 10).Add'Child |> hud.CreatePanel().TopLefted().AddChild
На глобальном уровне так делать не надо, но на столь коротких дистанциях этот код смотрится чище, чем любая альтернатива.
По тому же принципу можно создавать и более сложные конструкции. Здесь f (сделаем вид, что это сокращение от factory) создаёт новый объект:
do FG.VBoxContainer( CustomMinimumSize = Vector2.Right * 300f ).Add'Children [ let start, goal, cost, error = let f title color = let title = Label.TopCenter.Invoke( Text = title , LabelSettings = FG.LabelSettings( FontColor = color ) ) let value = Label.TopCenter.Invoke() {| Labels = [title;value] SetValueText = value.SetText SetVisible = fun visible -> [title;value] |> Seq.iter ^ fun p -> p.SetVisible visible |} f "Позиция корабля" SceneColors.title , f "Пункт назначения" SceneColors.title , f "Стоимость пути" SceneColors.title , f "Проблема" SceneColors.error do let rec go () = showPath path.Path disposables.Add ^ path.Changed.subscribe showPath and showPath path = printXY start path.Start printXY goal path.Goal match path.Result with | Ok result -> sprintf "%i" result.Core.Cost |> switchVisibility cost error | Result.Error err -> err.Stringify() |> switchVisibility error cost and printXY item point = item.SetValueText ^ sprintf "X = %i, Y = %i" point.X point.Y and switchVisibility visible hidden text = visible.SetValueText text visible.SetVisible true hidden.SetVisible false go () yield! start.Labels yield! goal.Labels yield! cost.Labels yield! error.Labels ] |> FG.MarginContainer(Margin = 10).Add'Child |> hud.CreatePanel().BottomRighted().AddChild
Объектов нужно аж четыре штуки, что в теории могло бы обернуться выделением отдельного типа (как обычно, на километровом расстоянии), но с задачей исчерпывающе справился анонимный рекорд. Сложнее всего здесь было придумать имя для объекта безымянного типа в printXY. Примечательно, что явная типизация в этой функции тоже не понадобилась, потому что локальный rec ... and описал буквально все входные данные ещё до того, как мы начали от них что-то требовать. Таким образом, вся логика, все зависимости и в общем-то весь тот мусор, что имеет обыкновение растекаться по проекту, оказался зажат в очень ограниченном пространстве, где его можно быстро подкорректировать или вовсе выпилить.
Как бы глубоко ни находился локальный скоуп, он всё ещё сохраняет доступ к внешнему миру, и потому в нём можно описывать операции, последствия которых выходят далеко за пределы своих скобок. Во-первых, мы можем дёргать готовое API:
asNode ^ Label.TopCenter.Invoke( Text = "Настройки изометрической сетки" , LabelSettings = FG.LabelSettings( FontColor = SceneColors.title ) ) FG.CheckButton( Text = "Отобразить внешнюю границу" , ButtonPressed = gridView.IsClosed , FocusMode = Control.FocusModeEnum.None , Pressed'Handler = disposables.HandleSignal ^ fun btn _ -> // Привязку к `ctrl` (как в прошлых главах) я убрал. gridView.IsClosed <- btn.ButtonPressed )
Во-вторых, мы можем собирать доступные данные и на основе них определять достаточно сложные команды прямо на глубине:
Label.TopCenter.Invoke( Text = "Препятствия" , LabelSettings = FG.LabelSettings( FontColor = SceneColors.title ) ) FG.Button( Text = "Заполнить 10%" , FocusMode = Control.FocusModeEnum.None , Pressed'Handler = disposables.HandleSignal ^ fun _ () -> if motion.IsSome then () else gridSize.AsSeqEks |> Seq.filter ^ fun p -> not ^ blocks.Contains { Position = p } && spaceship.Position <> p |> Array.ofSeq |> fun p -> Array.randomShuffleInPlace p Seq.truncate (p.Length / 10) p |> Seq.iter ^ fun p -> blocks.Change { Position = p } |> ignore path.InvalidatePathFinder gridSize blocks.Obstacles.Contains spaceship.Position )
В-третьих, мы можем сочетать все эти механизмы произвольным образом. Например, в следующем коде мы в дополнение к обычным контролам скрытно создаём ещё и диалоговое окно. Оно многоразовое и будет открываться всякий раз, когда пользователь попытается зачистить поле от блоков:
FG.VBoxContainer().Add'Children [ asNode ^ Label.TopCenter.Invoke( Text = "Опасная зона" , LabelSettings = FG.LabelSettings( FontColor = SceneColors.error ) ) let dialog = FG.ConfirmationDialog( Parent = main , Visible = false , InitialPosition = Window.WindowInitialPosition.CenterScreenWithKeyboardFocus , Title = "Очистить поле?" , DialogText = "Операция необратима." , OkButtonText = "Да" , CancelButtonText = "Нет" , Confirmed'Handler = disposables.HandleSignal ^ fun _ () -> blocks.Clear() path.InvalidatePathFinder gridSize blocks.Obstacles.Contains spaceship.Position ) FG.Button( Text = "Очистить" , FocusMode = Control.FocusModeEnum.None , Pressed'Handler = disposables.HandleSignal ^ fun _ () -> if motion.IsSome then () else dialog.Show() ) ] |> FG.MarginContainer( Margin = 10 ).Add'Child |> FG.PanelContainer( Panel = FG.StyleBoxFlat( BgColor = SceneColors.error.With(A = 0.1f) ) ).Add'Child |> asNode
Исполнение команды начинается с нажатия кнопки, но она не будет выполнена без подтверждения в раскрывшемся диалоговом окне. Таким образом, мы получаем пусть и очень короткую, но цепочку созависимых нод, всё поведение которых исчерпывающе описывается в точке спавна. Без необходимости модель об их состоянии не узнает.
Договариваемся на берегу
В какой-то момент нам может понадобиться получить доступ к объекту, который был создан глубоко внутри дерева. Процесс его извлечения наружу может проходить легко, а может сопровождаться разрывом такого количества связей, что мы просто взвоем. В последнем случае надо задуматься над тем, а нужно ли нам гонять этот объект на такие расстояния, или нам достаточно обмениваться с ним заранее установленными сообщениями. Вполне вероятно, что хватит одного ивента, созданного до спавна поддерева:
let buttonDisabledChanged = Event<_>() do [ let button = FG.Button( Text = "Заполнить 10%" , FocusMode = Control.FocusModeEnum.None , ... ) disposables.Add ^ buttonDisabledChanged.subscribe button.SetDisabled button ] |> ... let beginMotion currentPath = let newMotion = Motion.Model currentPath let disposables = disposables.CreateChild() let view = Motion.View(disposables, newMotion, font, grid.Cell) buttonDisabledChanged.Trigger true disposables.Add ^ IDisposable.create ^ fun () -> motion <- None buttonDisabledChanged.Trigger false ...
Дальше объекты в глубине поддерева и всё, что находится за пределами поддерева, могут общаться друг с другом только через этот ивент. Таким образом, мы имеем два лагеря, которые по большому счёту ничем друг от друга не отличаются. Их можно «поменять местами», и тогда глубинные объекты смогут работать с сущностями, которым только предстоит появиться на свет:
let dialogVisibilityChanged = Event<_>() do [ let dialog = FG.ConfirmationDialog( Parent = main , Visible = false , InitialPosition = Window.WindowInitialPosition.CenterScreenWithKeyboardFocus , Title = "Очистить поле?" , DialogText = "Операция необратима." , OkButtonText = "Да" , CancelButtonText = "Нет" , VisibilityChanged'Handler = disposables.HandleSignal ^ fun _ isVisible -> dialogVisibilityChanged.Trigger isVisible ) ... ] |> ... ... do disposables.Add ^ dialogVisibilityChanged.Publish.subscribe ^ fun isVisible -> ...
В абстрактном ничто мнение, утверждающее, что работать с коммуникациями проще, чем непосредственно с адресатами сообщений, будет звучать сомнительно. Потому что в общем случае оно неверно, и для его переоценки нужна существенная дополнительная нагрузка. Об этом факте следует помнить и использовать его как главный критерий при выборе «удалёнки». Кроме того, мы можем очень серьёзно удешевить коммуникации. В качестве образца рекомендую посмотреть на Hopac. Он в этом деле хорош, несмотря на то, что уже несколько версий языка не получал каких-либо обновлений.
Заключение
Я не стал детально разбирать тип Ready, так как после предыдущих глав в нём напрочь отсутствует новизна. Кому надо, может глянуть на него в итоговом репозитории.
Наверное, здесь стоило бы вставить сравнительный анализ моего решения для Godot + F# и тех, что уже существуют в публичном поле, но мне кажется, что он излишен, ввиду их крайней инаковости. Альтернативы можно нагуглить самостоятельно и быстро прийти к выводу, что мы ставим на совершенно разные аспекты F#. Мне безумно нравится то, как язык помогает решать задачи исключительно локальными средствами без привлечения внешних сил. Это обеспечивает монотонность развития. Независимо от размеров проекта мы технично продвигаемся к цели, не сбавляя скорость, потому что умеем вовремя сбрасывать всё, в чём отпала необходимость.
Большинство авторов фреймворков исповедует иной подход. Им крайне важна декларативность, причём в довольно вычурном ключе. Нужны портянки типов в близком к справочному виде, где генезис и условие существования этих типов никогда не играют существенной роли. Такие проекты хорошо смотрятся в README.md, но, с моей точки зрения, они бесполезны, потому что слишком дороги. Пройдёт немного времени — и эта справочность выйдет нам боком.
Дистанция между точкой определения типа и полем его применения — это всегда проблема, просто она может быть очень маленькой, если расстояние невелико. Исключение представляют только очень абстрактные ('a Option, Result<'ok, 'error>, 'a List и подобные) и очень конкретные (ScrollContainer.ScrollMode, Vector2 и другие) типы. Всё, что находится между этими крайностями, будет причинять какую-то долю боли, поэтому этой области следует по возможности избегать. Лучше всего сфокусироваться на «конкретном» крыле, а «абстрактное» оставить для особых случаев. Таким образом, мы сознательно отказываемся от концепции «баланса» и прочего шлака, и вместо этого давим в одном направлении до характерного хруста. Изредка при складывании хороших условий мы запускаем удачные абстракции в обратном направлении, но базовые установки от этого не изменяются.
На мой взгляд, это хороший способ держать проект и команду в тонусе так, чтобы у нас не заводился различный гуру-нуру-астро-психо-MBA-мусор, столь характерный для подхода «центристов» (моя экономика таких не тянет). Поэтому автоматический вывод типов (он же ХМ) для нас — это не просто синтаксический сахар, а способ держать наши силы в коде и в людях в едином организованном кулаке без включения серьёзного административного аппарата. Это важно, потому что всякий раз, когда у нас появляется хорошая возможность вломить, мы должны реализовывать её на полную глубину без согласований с третьими лицами. Иначе оперативность наших действий и общая эффективность упадут до (а может и ниже) среднебольничных показателей.

Наказания для тех, кто ответственен за упущенные возможности, в современной системе управления просто не предусмотрено, из-за этого процент людей, играющих «от обороны», всегда идёт вверх — и только вверх. Но, как и в играх, это поведение появляется не от опыта или большого ума, а от их недостатка. Поэтому наш отряд никак не защищён от внезапных лерой-атак до тех пор, пока мы не сократим контакты с этим классом персонажей до минимума. Парадигмы, что не учитывают наличие ХМ, изначально препятствуют этой минимизации и тем самым ставят нас в уязвимое положение. Их стоит отсекать на подлёте.
При всём при этом я вряд ли когда-либо буду бегать с топором за любителями чистого MVU и прочих излишне описательных решений. По крайней мере до тех пор, пока они не начнут влезать в мою епархию. Всё-таки подход имеет право на жизнь, если размеры приложения достаточно малы, или эта самая жизнь больше походит на сонное царство.
На этой статье цикл завершается. Он включает шестнадцать глав, что меня до сих пор удивляет. Его написание и публикация заняли у меня почти два года. Удручающий срок, если знать, что почти все высказанные в нём мысли были в моей голове ещё до начала цикла. Утешает лишь то, что мы затронули много тем, которые ранее либо вообще не освещались, либо раскрывались не в том ключе, в каком бы мне хотелось. В итоге у меня появился достаточно объёмный текст, который я могу рекомендовать начинающим (и не очень) F#-истам. Особенно в том случае, когда они собираются работать со мной или автономно. Плюс я нашёл несколько F#-истов, пробующих себя в геймдеве, которые в других обстоятельствах никогда бы не попали в мой круг общения.
Я постараюсь вернуться со следующим циклом ещё до осени. Поговорим про реактивное программирование в контексте Godot, пока что преимущественно на базе существующих решений. Позднее, если дело пойдёт, попробуем нечто более радикальное, быть может, скомпонованное в виде библиотеки, хотя поддержку nuget-пакета я бы предпочёл на себя не взваливать.
НЛО прилетело и оставило здесь промокод для читателей нашего блога: -15% на заказ нового VDS — HABRFIRSTVDS.
