Шестидесятилетний заключённый и лабораторная крыса. F# на Godot. Часть 15. Кульминация и полёт

от автора

В прошлой главе мы начали разбирать порт тайловых миров на F#, где познакомились с некоторыми малоизвестными возможностями Godot. В этот раз наш прицел сместится с технологических особенностей RenderingServer на обычную бытовуху (бизнес-логика + высокоуровневое рисование).

При этом следует понимать, что код разбираемого проекта предшествовал написанию текста и послужил первопричиной выбора тех тем, что попали в цикл. Поэтому нас ждёт очень много очень простого кода с примечаниями вида «это стало возможно благодаря <штуковине, что мы разбирали в цикле>». Конечно-же, я добавил некоторое количество экзотики, но сегодня наша задача — закончить с рисованием чего-либо (если не считать мини-карты, она вместе с GUI попала в последнюю главу) и собрать все заготовки в подобие игры.

Оглавление

Звездолёт

В прошлой главе я охарактеризовал ключевые типы сцены как различные по сложности сочетания из 3 компонент:

  • минимального набора входных данных и инвентаря (props);

  • нескольких рычагов управления (сеттеры и методы);

  • результирующего холста (: CanvasItemId).

Тип Spaceship в эту концепцию полностью укладывается:

type Spaceship (disposables, cellOps : IsometricGrid.CellOps) =    static let spaceshipTexture : Texture2D = GD.load """uid://oyqyauhtdyno"""    static let iconSize = 48f * Vector2.One    let surfaces = {|        Highlight = CanvasItemId.Create(Disposables = disposables)        Spaceship = CanvasItemId.Create(Disposables = disposables)    |}    let mutable direction = Vector2I.Zero    let mutable position = Vector2I.Zero    let mutable progress = 0f    let mutable isHighlightDirty = true    let mutable isSpaceshipDirty = true    let redraw () =        if isHighlightDirty then            surfaces.Highlight.Clear()            cellOps.Draw false 2f SceneColors.spaceship surfaces.Highlight position            isHighlightDirty <- false        if isSpaceshipDirty then            surfaces.Spaceship.Clear()            surfaces.Spaceship.AddPolygon(                Corners.clockwise false                     (cellOps.ToPixelCenter position                         - 0.5f * iconSize                        - 0.125f * (2f + cos progress) * iconSize._Y                    )                    iconSize.X_                    iconSize._Y                , [||]                , (                    Vector2.clockwiseCorners false                    |> Array.map ^ fun p ->                        (Vector2.One + p + direction.AsVector2) / 3f                )                , textureId = spaceshipTexture.TextureId            )            isSpaceshipDirty <- false    do redraw ()    let positionChanged = Event<_>()    member _.Direction        with get () = direction        and set value =            if direction <> value then                direction <- value                isSpaceshipDirty <- true    member this.LookToward target =        this.Direction <-             (target - position).AsVector2            |> SimplifyDirection.toward     member _.Position        with get () = position        and set value =            if position <> value then                let previous = position                position <- value                isHighlightDirty <- true                isSpaceshipDirty <- true                positionChanged.Trigger {|                    Previous = previous                    Current = position                |}    member _.PositionChanged = positionChanged.Publish    member _.Process delta =        if delta <> 0f then            progress <- progress + delta            isSpaceshipDirty <- true    member _.DeferRedraw () =        IDisposable.create redraw    member _.Surfaces = surfaces

1 Model -> 2 View

Первое, что бросается в глаза, — наличие сразу двух холстов в рамках одного типа. На одном из них мы рисуем корабль, на другом — зелёную рамку под кораблём. В совокупности получаем один узел управления и два визуальных представления, которые намертво прибиты друг к другу.

По моим наблюдениям, это решение — несмотря на свою примитивность (и даже топорность) — нетипично для большинства кодовых баз на Godot. И дело не в использовании холстов, ведь на их месте могли бы быть полноценные ноды:

type Hero (...) =    ...    member val Views = {|        Battlefield = ... // : Node2D        Minimap = ... // : Node2D        Skills = ... // : Control        Summary = ... // : Control        ...    |}

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

Кроме того, самостоятельное существование ноды провоцирует нас на вкорячивание поведения внутрь этой самой ноды, что зачастую лишь размазывает логику по проекту. Например, hero.Views.Battlefield — это просто Node2D, который некий внешний игрок забросит в battlefield.AddChild и его Position будет контролироваться либо Hero, либо тем же внешним игроком (как напишем). В такой схеме Views.Battlefield оказывается шариком без ручек, за которые можно дёргать, и без ножек, на которых он мог бы куда-нибудь убежать. Жёстко, но контролируемо. Разумеется, с ростом дистанции между данными и их проекцией, возникнут ситуации, когда вьюху таки придётся оторвать от модели, но до такого надо ещё дожить.

И снова rec

Холсты spaceship.Surfaces существуют с самого начала до самого конца сцены и располагаются следующим образом:

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            // Внутри лежат `spaceship.Surfaces.Spaceship` и холсты препятствий.        figureLayers.Surface    ]    |> Seq.iter rootLayer.AddCanvasItem

Так как surfaces.Highlight зафиксирован, о нём можно забыть. С surfaces.Spaceship всё сложнее. Звездолёт находится среди блоков препятствий, то для корректного отображения его холст должен всегда быть в слое с блоками, чей ZIndex совпадает с текущим ZIndex корабля. Для этого мы должны перекидывать surfaces.Spaceship между слоями figureLayers при каждом spaceship.PositionChanged:

do disposables.Add ^ spaceship.PositionChanged.subscribe ^ fun p ->    figureLayers.GetLayerByPosition p.Current    |> spaceship.Surfaces.Spaceship.SetParent    camera.Position <- grid.Cell.ToPixelCenter p.Current * gridTransform    // ...

Тут важно не забыть про первичное размещение слоя корабля, которое происходит до первого изменения позиции. Если его не сделать, то на старте до первого шага корабль вообще не будет видно. Связка Вызов + (Подписка => Вызов) встречается часто, и в большинстве языков она ведёт к созданию дополнительных функций и/или к ложным триггерам событий. В F# с ней справляются, по сути, также, но синтаксически чуть иначе:

// Не засоряем скоуп.do  let rec go () =        invalidate spaceship.Position        disposables.Add ^ spaceship.PositionChanged.subscribe ^ fun p -> invalidate p.Current    and invalidate position =        figureLayers.GetLayerByPosition position        |> spaceship.Surfaces.Spaceship.SetParent        camera.Position <- grid.Cell.ToPixelCenter position * gridTransform        // ...    // Тут надо не забыть про вызов.    // Но если всё находится в блоке do, компилятор нам об этом напомнит.    go ()

Разумеется, никаких рекурсивных вызовов (в привычном смысле) не ожидается, так что это ещё большее злоупотребление конструкцией let rec ... and, чем в главе 3. Она применена здесь только для того, чтобы сначала сказать, где и когда дёргается обработчик, и лишь потом определить его тело. При такой последовательности компилятор точно знает, какие типы будут у входных параметров функции, что будет важно в последующих примерах.

Дыхание жизни

Состояние Spaceship описывается 3 параметрами:

  • position — координата звездолёта.

  • direction — направление, в котором он смотрит (их доступно 8 штук).

  • progress — приватный прогресс сцены, из которого рассчитывается текущая высота корабля.

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

Я называю этот процесс «дыханием». Мне оно нужно по двум причинам. Во-первых, это красиво. Во-вторых, эта штука позволяет быстро диагностировать зависание (воспроизводимое каждый кадр падение) метода _Process в отсутствие IDE или редактора.

Идея проста: зависание цикличного индикатора означает зависание актора, который этим индикатором управляет. Разумеется, актор должен заниматься чем-то полезным, чтобы его тупняк действительно что-то значил. Кому доводилось погружаться в IoT, могли сталкиваться с тем, что аналогичную схему используют железячники. Они мигают светодиодами платы в основном цикле программы микроконтроллера. Если основной цикл по каким-то причинам зависает, светодиод перестаёт мигать (тупо горит или также тупо не горит), из чего внешний наблюдатель может сделать вывод, что устройству необходима перезагрузка.

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

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

Так как вся система наблюдения написана нами, мы можем автоматически отслеживать тихие смерти и делать их чуть погромче. Кроме того, мы никак не ограничены в формате вывода, поэтому можем генерировать изображения произвольной сложности. Так на правом варианте вместе с текущими данными выводятся последние 225 кадров, и среди них можно заметить следы ошибок прошлого. Кому доводилось работать с нестабильными железками, поймут, насколько это ускоряет диагностику.

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

Грязный, негрязный, грязный, …

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

Малый размер сцены позволяет на многое закрывать глаза, но по-хорошему при таких вводных перерисовка холстов не может происходить синхронно с изменением свойств. Надо дождаться всех правок в кадре и лишь потом приступать к рисованию. В Godot этот момент был решён через вынесение цикла перерисовки в отдельную фазу _Draw, исполнение которой не пересекается с _Process. По замыслу авторов, мы что-то делаем в _Process (и/или ещё где) и при необходимости вызываем QueueRedraw. Причём нам не надо беспокоиться, что мы что-то сломаем при повторном вызове QueueRedraw. Чуть позже движок скатает всё в один флаг (условный isDirty) и вызовет _Draw ровно по одному разу для каждой «грязной» ноды.

Холсты, созданные через RenderingServer, не подключены к механизму QueueRedraw, поэтому его пришлось нашаманить самостоятельно. У меня есть один подающий надежды вариант, который я иногда использую в «проде», но у меня также есть основания полагать, что всегда можно обойтись без него просто приглядевшись к особенностям конкретных сцен. Например, в нашем случае GD.Implements._process присутствует в единственном экземпляре, поэтому нам нет разницы, вызывается ли redraw в отдельной фазе после отработки метода или в его конце. Последний вариант можно легко набросать через use:

    member _.DeferRedraw () =        IDisposable.create redraw
do main.AddChild ^ GD.Implements._process ^ fun _ delta ->    let delta = float32 delta    use _ = spaceship.DeferRedraw()    underMouse.Process ()    ...    // Индикатор обновляем в самом конце,    // чтобы всё, что могло упасть, успело упасть.    spaceship.Process delta    // Сюда компилятор засунет вызов `redraw`.

Наше распределение ответственности чуть отличается от движочного, что может быть незаметно при поверхностном взгляде. Дело в том, что свойства Spaceship самостоятельно поднимают зависимые флаги isDirty<canvasName> (= .QueueRedraw()). После чего эти флаги надо проверить и при необходимости перерисовать холсты. Вот за эту невидимую в условиях Godot операцию отвечаю DeferRedraw.

Предсказатель пути

Модуль PathPredictor отвечает за поиск пути в условиях сцены и за визуализацию этого пути перед игроком.

Data

Процесс поиска пути мы пытали 4 главы (или даже 7, смотря как считать). Тем не менее при подключении к сцене его всё равно пришлось дорабатывать.

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

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

Обе проблемы могут решиться заменой PathFinder.Error:

type Error =    | Obstacle    | OutOfBounds    | Unreachable 

На что-то такое:

type Error =    | AlreadyInGoal    | Obstacle    | OutOfBounds    | Unreachable of {| Ready : ...; Paths : ... |}

Однако затрагивать исходный код я не стал, так как это повлекло бы каскадные изменения в остальном проекте (он был больше презентуемого). Возникли бы вопросы, на которые у меня нет хороших ответов. Вместо этого я завёл ещё одно DU и переупаковал данные старого в новое непосредственно в сцене:

module PathPredictor =    type UnreachableData (core) =        member this.Core = core    type PathFound (core) as this =        static let rec tryCreate (pathFinder : PathFinder.Main4) goal =            pathFinder.TryFind goal            |> Result.map ^ fun p ->                PathFound {| p with Ready = pathFinder.Ready |}            |> Result.mapError ^ Error.OfPathFinderError pathFinder            let core = {| core with Shell = this |}            static member TryCreate (pathFinder, goal) =            tryCreate pathFinder goal            member _.Core = core            member _.Costs =            core.Paths            |> Seq.map ^ fun p -> p.Key, p.Value.Last.Cost            member _.Path =            core.Path            |> Seq.map ^ fun p -> p.Position    and Error =        | AlreadyInGoal        | Obstacle        | OutOfBounds        | Unreachable of UnreachableData            with        member this.Stringify () =            match this with            | Obstacle -> "В пункте назначения препятствие."            | OutOfBounds -> "Пункт назначения за пределами поля."            | Unreachable _ -> "Пункт назначения недостижим."            | AlreadyInGoal -> "Корабль уже в пункте назначения."            static member OfPathFinderError finder err =            match err with            | PathFinder.Obstacle -> Obstacle            | PathFinder.OutOfBounds -> OutOfBounds            | PathFinder.Unreachable -> Unreachable ^ UnreachableData {| Ready = finder.Ready; Paths = finder.Paths |}

Здесь мы видим достаточно сложную по меркам F# мешанину типов. Происходит она от моей любви плодить типы под конкретную задачу и одновременно с этим до последнего избегать явной типизации. Оба желания были успешно удовлетворены. Я указал лишь на то, что pathFinder имеет тип PathFinder.Main4, о чём практически невозможно догадаться без подсказок, а всё остальное за меня вывел компилятор. Причём это отразилось не только на уровне функций, но и в типах (детально как это работает разбирали в 6 главе и 7 главе), что будет использовано в последующих модулях.

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

Model

PathPredictor.Model — это сменяемый PathFinder, результат его изысканий и обвязка вокруг них:

module PathPredictor =    ...    type Model (finder) =        static let create gridSize isObstacle spaceshipPosition =            PathFinder.Main4(gridSize, isObstacle, spaceshipPosition)            |> Model                    let mutable finder = finder        let buildPath goal = {|            Start = finder.Start            Goal = goal            Result =                if goal = finder.Start                then Result.Error Error.AlreadyInGoal                else PathFound.TryCreate(finder, goal)        |}        let mutable path = buildPath finder.Start        let changed = Event<_>()        let invalidatePath goal =            path <- buildPath goal            changed.Trigger path        member _.Path = path        member _.Changed = changed.Publish        member _.InvalidatePath goal =            invalidatePath goal        member this.InvalidatePathFinder gridSize isObstacle spaceshipPosition =            finder <- PathFinder.Main4(gridSize, isObstacle, spaceshipPosition)            this.InvalidatePath path.Goal        static member Create gridSize isObstacle spaceshipPosition =            create gridSize isObstacle spaceshipPosition

По этому коду сложно сказать что-то новое. Его также сложно оптимизировать, так как всё, что можно было сэкономить, было или будет сэкономлено в других типах.

View

Визуальное представление пути — это два холста. На UnderGrid мы закрашиваем клетки. На OverGrid подписываем их стоимость. Лежать они будут так:

[    motionLayers.UnderGrid    // Статус клеток.    pathView.Surfaces.UnderGrid    gridView.Surface    spaceship.Surfaces.Highlight    motionLayers.OverGrid    // Стоимость/удалённость клеток.    pathView.Surfaces.OverGrid        underMouse.Surface    figureLayers.Surface]|> Seq.iter rootLayer.AddCanvasItem

А рисоваться так:

module PathPredictor =    ...    type View (disposables, model : Model, font, cellOps : IsometricGrid.CellOps) =        let surfaces = {|            UnderGrid = CanvasItemId.Create(Disposables = disposables)            OverGrid = CanvasItemId.Create(Disposables = disposables)        |}        do  let rec go () =                invalidate model.Path                disposables.Add ^ model.Changed.subscribe invalidate            and invalidate path =                 surfaces.UnderGrid.Clear()                surfaces.OverGrid.Clear()                seq {                    match path.Result with                    | Ok path ->                        for position, _ in path.Costs do                            SceneColors.seen, position                        for position in path.Core.Ready.Keys do                            SceneColors.explored, position                        for step in path.Core.Path do                            SceneColors.path, step.Position                        SceneColors.goal, path.Core.Goal                    | Result.Error (Error.Unreachable p) ->                        for position in p.Core.Paths.Keys do                             SceneColors.seen, position                        for position in p.Core.Ready.Keys do                            SceneColors.explored, position                        SceneColors.unreachableGoal, path.Goal                    | _ ->                         SceneColors.unreachableGoal, path.Goal                }                |> Seq.iter ^ fun (color, cell) ->                    cellOps.Fill surfaces.UnderGrid color cell                seq {                    match path.Result with                    | Ok path ->                        yield! path.Costs                    | Result.Error (Unreachable p) ->                        for p in p.Core.Paths do                            p.Key, p.Value.Last.Cost                    | _ ->                        ()                }                |> Seq.iter ^ fun (key, value) ->                    let text = string value                    let fontSize = 12                    let textSize = (font : Font).GetStringSize(text, fontSize = fontSize)                                    font.DrawString(                        surfaces.OverGrid.AsRid                        , cellOps.ToPixelCenter key                             + 0.5f * Vector2.Left * textSize                            + Vector2.Down * font.GetDescent fontSize                        , text                        , fontSize = fontSize                    )            go ()        member _.Surfaces = surfaces        member _.Hide () =            let f visible =                [                    surfaces.UnderGrid                    surfaces.OverGrid                ]                |> List.iter ^ fun p -> p.SetVisible visible            f false            IDisposable.create ^ fun () -> f true

Рисование оказывается достаточно простым актом благодаря тому, что все необходимые данные были заранее подсчитаны, упакованы и доставлены в нужное место в готовом к употреблению виде.

View — намертво прибит к Model, но при этом лишён доступа к приватной части модели, поэтому потенциально его можно определить далеко за пределами модуля. В последующих циклах для явлений подобных Model.Path мы будем использовать абстракции по смыслу похожие на 'a Hopac.Stream.Var (или 'a SubjectBehavior из RX). При таком ракурсе View окончательно приобретёт черты проекции, но думать о нём в таком ключе стоит уже сейчас.

Перемещение корабля

Корабль перемещается из точки А в точку Б не моментально, а по шагам, что подразумевает наличие отдельного состояния. Это состояние временно, что может быть отражено как внутри соответствующих типов (условный option), так и вовне. По умолчанию в F# я придерживаюсь подхода, согласно которому мы сначала определяем модель, которая не ведает о своём небытии, и лишь потом при необходимости облекаем в типы цикл «бытие <-> небытие». По этому пути был собран модуль Motion. Он не рассуждает об экзистенциальных угрозах, а делегирует их вовне.

Model

Ядро модели — member _.Process delta. Мы косвенно затрагивали этот метод несколько глав назад, когда обсуждали преимущества и недостатки списочного представления путей. Из-за производственной деформации я воспринимаю этот алгоритм как классическую рекурсию, управление которой было передано движку, что не лучшим образом сказалось на её читаемости:

module Motion =    type 'a Changed =        | Completed        | Moved of 'a    type Model (path : PathPredictor.PathFound) =        static let stepTime = 0.123f        let mutable next =            path.Core.Path            |> List.ofSeq             |> List.tail         let mutable progress = 0f        let mutable isCompleted = false        let changed = Event<_>()        member _.Process delta =            if isCompleted then () else            progress <- progress + delta            if progress > stepTime then                progress <- progress - stepTime                match next with                | [] ->                    isCompleted <- true                    changed.Trigger Completed                | current :: newNext ->                    next <- newNext                    changed.Trigger ^ Moved {|                        Current = current                        Remain = next                    |}        member _.Changed = changed.Publish        member _.Remain = next                    member _.Path = path

Model не управляет позицией корабля напрямую, и вообще не имеет к нему доступа. Вместо этого он покадрово прокручивает путь и сообщает о результатах наружу через changed.Trigger ^ Moved. Там эти сообщения подхватывают и соответствующим образом воздействуют на позицию и поворот Spaceship.

Примечательно, что кейс Changed.Moved определён как дженерик, хотя его содержимое видится вполне конкретным. Обычно обобщение используется для того, чтобы складывать в тип данные различных типов. Однако в нашем случае это было сделано, чтобы мне не пришлось (мне лень) указывать тип данных в Moved ни в явном виде, ни косвенно через оболочку (как в случае с PathPredictor.UnreachableData).

Этот быстрорастворимый хак мог бы стать проблемой, если бы наш Changed.Moved использовался в нескольких независимых узлах по всему проекту. Но Changed не просто так лежит внутри Motion. Он прибит к конкретному источнику данных, и всякий подписавшийся на Model.Changed будет точно знать, с чем имеет дело. Существование альтернативных источников не предполагается, но даже если где-то и появится ивент со схожей семантикой, мы не будем ничего растягивать, а просто бахнем ещё один Changed в module NewModule, и все останутся при своих.

View

Здешний View не отвечает за анимацию движения. Вместо этого он раскрашивает поле на время перемещения корабля. В теории для этих целей мы могли бы клонировать холсты от PathPredictor.View, но я скорректировал раскраску клеток пути, чтобы момент перемещения ощущался пользователем иначе:

module Motion =    ...    type View (disposables, model : Model, font, cellOps : IsometricGrid.CellOps) =        let surfaces = {|            UnderGrid = CanvasItemId.Create(Disposables = disposables)            OverGrid = CanvasItemId.Create(Disposables = disposables)        |}        do  model.Path.Costs            |> Seq.iter ^ fun (key, value) ->                cellOps.Fill surfaces.UnderGrid SceneColors.seen key                let text = string value                let fontSize = 12                let textSize = (font : Font).GetStringSize(text, fontSize = fontSize)                            font.DrawString(                    surfaces.OverGrid.AsRid                    , cellOps.ToPixelCenter key                         + 0.5f * Vector2.Left * textSize                        + font.GetDescent fontSize * Vector2.Down                    , text                    , fontSize = fontSize                )            for position in model.Path.Core.Ready.Keys do                cellOps.Fill surfaces.UnderGrid SceneColors.explored position            for step in model.Path.Path do                cellOps.Fill surfaces.UnderGrid SceneColors.motion step                    member _.Surfaces = surfaces

Так как картинка статична, никаких перерисовок или подписок на события в тип не попало. Зато они попали во внешнюю обвязку, которую целесообразно разобрать вместе с итоговым кодом сцены.

Промежуточный интерактив

Законченный проект будет представлен в следующей главе, а сейчас просто соберём все типы в кучу, чтобы у нас получилось что-то работающее «на потыкать». Экспозиция получилась объёмная:

type Ready1 (main : Node) =    inherit Node()    static let gridTransform =        Transform2D            .Identity            .Scaled(2f * Vector2.One)    static let inversedGridTransform =        gridTransform.AffineInverse()    static let gridSize =        Vector2I(24, 24)            // Нужен CanvasItem.    let main = FG.Node2D(Parent = main)    let disposables = IDisposable.composite()                do  main.GetWindow().Name <- "Chapter 15"        RenderingServer.SetDefaultClearColor SceneColors.background    let camera = FG.Camera2D(        Parent = main        , PositionSmoothingEnabled = true        , PositionSmoothingSpeed = 4f    )    let font = main.GetWindow().GetThemeDefaultFont()    let grid = IsometricGrid.Main(60f, 1.43f)    let gridView = GridView(disposables.CreateChild(), grid.Grid, gridSize)    let underMouse = UnderMouse(        disposables.CreateChild()        , grid.Cell        , fun () -> inversedGridTransform * main.GetLocalMousePosition()    )    let figureLayers = FigureLayers(disposables.CreateChild())    let blocks = Blocks.Main(disposables.CreateChild(), 0.5f, figureLayers.GetLayerByZIndex, grid.Cell)    let spaceship = Spaceship(disposables, grid.Cell)    let pathPredictor = PathPredictor.Model.Create gridSize blocks.Obstacles.Contains spaceship.Position    let pathView = PathPredictor.View(disposables.CreateChild(), path, font, grid.Cell)    let motionLayers = {|        UnderGrid = CanvasItemId.Create(Disposables = disposables)        OverGrid = CanvasItemId.Create(Disposables = disposables)    |}    let mutable motion = None

Здесь всё должно быть ясно, кроме кейса motion и его холстов. Я не стал выносить процесс управления передвижением в отдельный тип, поэтому нам придётся поработать с ним руками. Столь канительный подход мне не нравится, но цикл уже заканчивается, а мне хочется оставить в нём нечто раздражающее, к чему можно будет вернуться позднее (с ультимативным лекарством), и что читателями может быть исправлено своими силами на своё усмотрение.

motionLayers — это пустые холсты-слоты по типу тех, что мы использовали в FigureLayers (только здесь имеет отношение 1 к 1, а не 1 ко многим). Они существуют независимо от motion и занимают чёткую позицию в иерархии слоёв. Временные холсты Motion.View.Surfaces будут забрасываться внутрь слотов в момент своего спавна, и благодаря этому будут отображаться ровно там, где надо. В классических GUI-фреймворках этот манёвр сопряжён с риском что-то не туда сдвинуть, добавить куда-нибудь лишний отступ и т.д., но в случае с холстами и прочими автономными Node2D это работает на ура:

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    ]    |> Seq.iter rootLayer.AddCanvasItem

Подписка на Spaceship.PositionChanged (мелькала выше) должна быть дополнена взаимодействием с PathPredictor, который необходимо пинать при каждом перемещении корабля:

do  let rec go () =        invalidate spaceship.Position        disposables.Add ^ spaceship.PositionChanged.subscribe ^ fun p -> invalidate p.Current    and invalidate position =        figureLayers.GetLayerByPosition position        |> spaceship.Surfaces.Spaceship.SetParent        camera.Position <- grid.Cell.ToPixelCenter position * gridTransform        pathPredictor.InvalidatePathFinder gridSize blocks.Obstacles.Contains position    go ()

Подписка на UnderMouse.CellChanged повторяет себя из прошлой главы и дополнительно пинает PathPredictor и Spaceship:

do  let rec go () =        invalidate underMouse.Cell        disposables.Add ^ underMouse.CellChanged.subscribe ^ fun ev ->            blocks.TryGetBlock ev.Previous            |> Option.iter ^ fun p -> p.IsUnderMouse <- false            invalidate ev.Current    and invalidate current =        blocks.TryGetBlock current        |> Option.iter ^ fun p -> p.IsUnderMouse <- true        pathPredictor.InvalidatePath underMouse.Cell        if motion.IsNone then            spaceship.LookToward current    go ()

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

Запуск анимации перемещения выглядит так:

let beginMotion currentPath =    let newMotion = Motion.Model currentPath    let disposables = disposables.CreateChild()    let view = Motion.View(disposables, newMotion, font, grid.Cell)    view.Surfaces.UnderGrid.SetParent motionLayers.UnderGrid    view.Surfaces.OverGrid.SetParent motionLayers.OverGrid    disposables.Add ^ IDisposable.create ^ fun () ->        motion <- None    disposables.Add ^ pathView.Hide()    disposables.Add ^ newMotion.Changed.subscribe ^ fun ev ->        match ev with        | Motion.Changed.Completed ->            disposables.Dispose()        | Motion.Changed.Moved ev ->            spaceship.LookToward ev.Current.Position            spaceship.Position <- ev.Current.Position    motion <- Some {| Process = newMotion.Process |}

По сути своей это самая F#-овая штука из всех, что были описаны в этой сцене. Мы восприняли контекст, развернулись, запустились, описали процедуру зачистки и скрылись, оставив после себя минимально возможный след. Тут есть, что улучшить (тот же motion), но общая парадигма и наши дальнейшие устремления должны быть понятны уже сейчас.

Наконец, _Process:

do main.AddChild ^ GD.Implements._process ^ fun _ delta ->    let delta = float32 delta    use _ = spaceship.DeferRedraw()    underMouse.Process ()            gridView.IsClosed <- not ^ Input.IsKeyPressed Key.Ctrl                    match motion with    | Some motion ->        motion.Process delta    | None ->        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            pathPredictor.InvalidatePathFinder gridSize blocks.Obstacles.Contains spaceship.Position                        match pathPredictor.Path.Result with        | Ok path when Input.IsActionJustPressed "mouse_right" ->            beginMotion path        | _ -> ()                if Input.IsKeyPressed Key.Escape then        main.GetTree().Free()    spaceship.Process delta

Тут опять возимся с motion, но в остальном код прост как пробка.

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

Репозиторий с состоянием на конец данной главы можно посмотреть здесь.

Цикл ждёт ещё одна глава, но уже сейчас должно быть видно, как отвлечённые понятия из начала цикла проявляют себя в коде как на локальном, так и на глобальном уровне, так что пришла пора поговорить о том, почему этот цикл называется так, как называется. «Шестидесятилетний заключённый и лабораторная крыса» — это цитата из фильма «Скала» (в оригинале «The Rock»), древнего боевичка аж 1996 года выпуска. Эту характеристику использует один из агентов ФБР в отношении главных героев фильма, когда те вдвоём оказываются одни на острове против дюжины вооружённых и хорошо подготовленных солдат. Вопреки такой низкой (и вообще-то верной) оценке, эти двое побеждают, после чего зек-пенсионер благополучно смывается, а лабораторная крыса вместе с беременной женой и компроматом на всё ФБР уезжает в закат (все делаем вид, что «нынче ФБР уже не то» и за ними никто не придёт).

Именно это несоответствие стартовых условий и конечного результата — хорошая аналогия тому, как у F# получается быть хорошим языком, и почему одновременно с этим он имеет трудности с распространением. Дело в том, что хороший код на F# — это всегда сплав заоблачных абстракций с вопиюще приземлённой конкретикой. Мы сочетаем то, что (по мнению «реалистов») не существует, с тем, что (по мнению «пуристов») не должно существовать, а в результате получаются работающие программы. У такого подхода есть 3 проблемы.

  • Заоблачные абстракции. Широко известен парадокс Блаба, согласно которому несовершенство инструмента (н-р., языка программирования под названием Блаб) само по себе отнюдь не всегда приводит к отказу от него, так как его владелец по умолчанию думает в категориях этого самого инструмента. Необходим пинок извне, чтобы человек узнал об ином подходе и воспринял изъян своего как вызов и проблему. Заметная часть людей давно научилась пинать себя самостоятельно (остальных в утиль), поэтому с освоением абстракций у нас непреодолимых трудностей не наблюдается. Я регулярно встречаю неофитов с уровнем теоретической подготовки значительно выше моего.

  • Вопиющая конкретика. С ней мы конкретно завязли. Мой пропагандистский опыт чаще всего напарывается на рассуждения вида «моя проблема выглядит иначе» или даже «твоё решение уникально и работает только в твоём случае». От некоторых персонажей я могу получать эти замечания независимо от количества приводимых примеров. С одной стороны, мне льстит тот факт, что я наловчился собирать базовые компоненты в нечто настолько удачно индивидуальное, комплексное и монолитное, что неподготовленный наблюдатель теряет способность к переносу опыта на свою задачу. С другой стороны, я понятия не имею, как бороться с такой риторикой без препарирования задач оппонентов.

  • Синтез «заоблачных абстракций с вопиюще приземлённой конкретикой». В глазах публики такой союз выглядит противоречиво, поэтому он не считывается и не воспринимается как рецепт успеха, даже если на него явно указывать. Я не знаю, как перебороть этот момент системно, и пока что побеждает подход, согласно которому я просто работаю над насмотренностью аудитории. За счёт этого характер задаваемых мне вопросов постепенно меняется с «как принято у вас?» на «а что нам может помешать сделать так?». Забавно, что чем богаче опыт, тем проще отвечать на второй тип вопросов (и сложнее на первый). Поэтому с пяток «так нельзя» гораздо быстрее приводят к удачному решению, чем попытка следовать спорному или даже несуществующему канону.

Из этих наблюдений проистекают мои дальнейшие планы на Хабре. Монолог продолжится в этом же ключе, пусть и на более сложных примерах. Однако если вы дочитали до этих строк, вы узнали мои намерения и можете самостоятельно скорректировать восприятие прошлых и будущих текстов в нужном направлении, вступив со мной в подобие диалога.


В следующей главе нас ждёт мини-карта, UI и парочка внутриигровых функций.


НЛО прилетело и оставило здесь промокод для читателей нашего блога: -15% на заказ нового VDSHABRFIRSTVDS.

Положение об акции

ссылка на оригинал статьи https://habr.com/ru/articles/1049342/