
В прошлой главе мы начали разбирать порт тайловых миров на F#, где познакомились с некоторыми малоизвестными возможностями Godot. В этот раз наш прицел сместится с технологических особенностей RenderingServer на обычную бытовуху (бизнес-логика + высокоуровневое рисование).
При этом следует понимать, что код разбираемого проекта предшествовал написанию текста и послужил первопричиной выбора тех тем, что попали в цикл. Поэтому нас ждёт очень много очень простого кода с примечаниями вида «это стало возможно благодаря <штуковине, что мы разбирали в цикле>». Конечно-же, я добавил некоторое количество экзотики, но сегодня наша задача — закончить с рисованием чего-либо (если не считать мини-карты, она вместе с GUI попала в последнюю главу) и собрать все заготовки в подобие игры.
Оглавление
Приквел
Шестидесятилетний заключённый и лабораторная крыса. F# на Godot
-
Часть 15. Кульминация и полёт // мы здесь
Звездолёт
В прошлой главе я охарактеризовал ключевые типы сцены как различные по сложности сочетания из 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% на заказ нового VDS — HABRFIRSTVDS.
ссылка на оригинал статьи https://habr.com/ru/articles/1049342/