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

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

За остаток цикла мы должны выяснить, как можно строить среднеразмерные игровые сцены на F#, а также почему этот цикл называется так, как называется. Конкретно в этой главе мы разберёмся с RenderingServer-ом (бывший VisualServer), но не с нуля, а с позиций достигнутых в »Godot — рисование без правил» и »Прямоугольные тайловые миры». Если вы уже прочитали эти статьи, то вам должно быть известно, что рисование через RenderingServer — это довольно экзотический подход, который воспринимает хождение сквозь стены как рядовое событие. Авторы движка этому не препятствуют, но и не содействуют, поэтому документации по этому серверу — кот наплакал. Мне так и не удалось выудить ответы на все интересующие меня вопросы в словесной форме и их пришлось выковыривать при помощи живых экспериментов. Последние ни в статью, ни в проект не влезли, но тут важна сама парадигма, при которой мы всегда отталкиваемся от практики, а не от теории, которой пока просто нет.

Оглавление

Структура проекта

Проект выглядит следующим образом:

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

let summonFSharp node =
    match string (node : Node).Name with
    | "EntryPoint" ->
        do ()
    | "CmdlineUserArgs" ->
        OS.GetCmdlineUserArgs()
        |> List.ofSeq
        |> function
            | []
            | "Chapter14" :: [] ->
                Sandbox0.initWhenReady0 node
            | other ->
                GD.whenReady node ^ fun () ->
                    node.AddChild ^ FG.Label(
                        Text = sprintf "Unexpected cmdline user args: %A" other
                    )
    | unexpected ->
        GD.print $"unexpected node.Name: %A{unexpected}"

В оригинальном цикле рассматривалось несколько координатных сеток, а также парочка подзадач типа поиска пути или растеризации геометрических фигур. Когда-то я переписал на F# их все, но в этом проекте я ограничился переносом изометрической сетки и путей, так как всё остальное либо дублирует представленное, либо не даёт нам нужных примеров для статьи.

В новом проекте есть 4 файла, которых не было в прошлом:

  • RenderingServerDSL.fs — это файл с DSL для RenderingServer. Он имеет универсальный характер, и его можно без правок использовать где-либо ещё.

  • IsometricGrid.fs — это код изометрической сетки, в котором лежит как математический аппарат, так и отрисовка.

  • PathFinder.fs — поиск пути. Этот модуль многократно переписывался, чтобы я мог раскрыть как можно большее число аспектов F#, так что он был забит промежуточными версиями поиска. Большую часть из них я выпилил, но кое-что в образовательных целях или из-за банального недосмотра оставил. Причёсывать не стал — учитывайте это, когда столкнётесь в коде с чем-то, что будет трудно объяснить потребностями текущего проекта.

  • Sandbox0.fs — код сцены и единственный файл, который будет рассматриваться детально. Он является куском оригинального модуля и в следующих главах появятся его продолжения типа Sandbox1.fs и т. д.

Мелкие бытовые радости. Выпуск №1

Мне, как и большинству, очень сложно понять в какой цвет в итоге сложатся числовые компоненты, поэтому я держу рядом какое-нибудь приложеньице для очного наблюдения и экспорта/импорта цветовых кодов. Для этих целей можно приспособить тот же редактор. Его активные пользователи могут складывать цвета в мету и потом распространять их оттуда по всему приложению:

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

module SceneColors =
    let background = Color.FromString("#122d5b", Colors.Black)
    let explored = background.With(V = 1f)
    let seen = background.Lerp(explored, 0.5f)

    let path = Colors.Orange
    let goal = Colors.OrangeRed
    let motion = path.Lerp(goal, 0.5f)
    
    let unreachableGoal = Colors.DarkRed
    
    let block = Colors.LightBlue
    let grid = Colors.White
    
    let spaceship = Colors.Green
    let underMouse = Colors.Wheat

    let title = Colors.LightSkyBlue
    let error = Colors.LightCoral

Например, здесь background был найден пипеткой по картинке с блюпринтами (реальным, а не из UE). Далее explored (цвет изученных клеток) был выражен как тот же background, но с выкрученной до максимума компонентой Value (схема HSV). Наконец seen (цвет клеток с предварительной промежуточной оценкой) был обозначен как цвет находящийся ровно посерёдке между explored и background. Аналогичным образом по отношению к path и goal вычисляется цвет motion. Не факт, что эти соотношения доживут до релиза, но на этапе прототипирования такая эксплуатация компонентов HSVA и векторных операций над цветами экономит наше время.


Ещё одна мелочёвка, которая сказывается на «качестве жизни» — это вот такие расширения:

type CanvasItemId with
    // С неймингом у меня беда-беда, но для цикла пойдёт, а дальше что-нибудь придумается.
    member this.Disposables
        with set value =
            (value : System.Reactive.Disposables.CompositeDisposable).Add ^ IDisposable.create this.Free

type Resource with
    member this.Disposables
        with set value =
            (value : System.Reactive.Disposables.CompositeDisposable).Add ^ IDisposable.create this.Free

type Node with
    member this.Disposables
        with set value =
            (value : System.Reactive.Disposables.CompositeDisposable).Add ^ IDisposable.create this.QueueFree

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

CanvasItemId.Create(Disposables = disposables)

То компилятор автоматически выведет тип disposables как System.Reactive.Disposables.CompositeDisposable, и в дальнейшем его можно будет использовать без дополнительных указаний. По сути, речь идёт об аналоге некоего CompositeDisposable.addNode для мира сеттерных DSL.

Сетка

За рисовку сетки (в буквальном смысле) отвечает вот такой тип:

type GridView (disposables, grid : IsometricGrid.GridOps, gridSize) =
    let surface = CanvasItemId.Create(Disposables = disposables)

    let mutable isClosed = true

    let redraw () =
        surface.Clear()
        let draw =
            if isClosed
            then grid.DrawClosed
            else grid.DrawOpened
        draw false 1f SceneColors.grid surface gridSize

    do redraw ()

    member _.Surface = surface

    member _.IsClosed
        with get () = isClosed
        and set value =
            if value <> isClosed then
                isClosed <- value
                redraw()

Он строится на основе фиксированного размера поля и некоего набора операций над сеткой (их разбирали в 9 главе). Видимость внешней границы сетки можно задавать снаружи в рантайме, и при изменении этой характеристики тип будет автоматически перерисовывать содержимое холста surface : CanvasItemId (включая изображение на экране). Сам холст экспонируется наружу, и его можно считать визуальной проекцией или просто output-ом типа GridView.

Контроль ресурсов осуществляется через входящий CompositeDisposable. Когда он умрёт, холст освободится. Формально экземпляр типа сможет функционировать дальше, что позволит ему дёргать уже мёртвый CanvasItemId. RenderingServer такое не любит и будет жаловаться на это в логах. Деятельность GridView можно прекратить тотально при помощи того же disposables, однако текущее устройство приложения позволяет игнорировать эту угрозу как невероятную.

В общих чертах так будут выглядеть почти все последующие типы:

  • Минимальный набор входных данных и зависимостей (props);

  • Набор рычагов воздействия;

  • Результат в виде одного или нескольких холстов.

Однако каждый следующий тип будет устроен чуточку сложнее или иначе, чем предыдущий. На разборе этих эволюций и будет построено повествование.

Система слоёв

В «Рисовании без правил» и т. д. использовался очень примитивный сценарий отрисовки. В нём у нас на всё про всё был только один холст surface : CanvasItemId. После каждого изменения мы этот холст очищали и в несколько этапов разрисовывали по новой с учётом текущего состояния системы. Чем больше в системе становилось движущихся деталей, тем чаще, объёмнее и бессмысленнее получалась перерисовка. Так что, когда я переносил проект с GDScript на F#, то в первой итерации у меня образовался очень хрупкий Draw-метод километровой длины.

Автор исходного текста был занят объяснением устройства тайловых сеток, поэтому у него просто не было возможности сделать всё более структурно (глав этак на 15). У меня же эта возможность есть, так что применим более сложную архитектуру. Мы будем использовать целые деревья холстов, а также матрицы трансформаций, камеру и может быть что-то ещё. Знакомьтесь, наша система слоёв:

// Нужен CanvasItem, в то время как SummonFSharp его наследником не является.
let main = FG.Node2D(Parent = main)

// ...

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

К данному моменту мы знакомы только с gridView.Surface, но я надеюсь, что общая структура уже ясна.

Напомню, что наши CanvasItemId — это сильно облегчённые CanvasItem-ы из которых убрали всю Node-ную составляющую. Наше взаимодействие с ними сводится к очень ограниченному набору команд, но в целом они ведут себя очень похоже на больших собратьев и подчиняются той же логике. Чем выше холст в списке, тем дальше он от пользователя. Ну или, говоря по-простому, у нас тут как в Photoshop наоборот. То есть слои снизу списка накрывают собой слои с верха, поэтому наша сетка будет рисоваться только поверх тех холстов, что отвечают за площадную раскраску клеток.

Матрицы трансформации в оригинальном цикле упоминались, но в коде не использовались. Здесь они нужны, чтобы увеличить в 2 раза всё поле, блоки и космический корабль:

static let gridTransform =
    Transform2D
        .Identity
        .Scaled(2f * Vector2.One)
// Нужно для вычисления позиции курсора в локальных координатах.
static let inversedGridTransform =
    gridTransform.AffineInverse()

Так как все слои лежат в rootLayer, к которому применили Transform = gridTransform, то все вложенные слои также подхватят этот рескейл. Всё как в CanvasItem-ах.

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

let camera = FG.Camera2D(
    Parent = main
    , PositionSmoothingEnabled = true
    , PositionSmoothingSpeed = 4f
)

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

Слои препятствий

В оригинальной статье рисовка блоков препятствий предполагала сложную машинерию. Мы должны были свалить в кучу все блоки и единственный космический корабль, отсортировать их по ZIndex, и только потом последовательно отрисовать. При нарушении технологии блоки начинали не в том порядке перекрывать друг друга и корабль, в результате чего получалась абстрактная обложка для учебника по геометрии. В нашей версии математический аппарат ZIndex остался тем же (ZIndex = x + y), но существенная часть процесса сортировки была переложена на плечи движка.

Каждый блок, а также космический корабль, имеют свой собственный CanvasItemId, в котором они могут рисовать без оглядки на остальных. Эти CanvasItemId объединяются в группы с одинаковым ZIndex и кладутся в «слои», роль которых исполняют CanvasItemId с настроенным свойством ZIndex. Чтобы эти слои не конфликтовали со слоями без ZIndex-ов (типа gridView.Surface), их положили ещё в один CanvasItemId:

- FigureLayers.Surface
    - Layer
        - Block.Surface
    - Layer
        - Block.Surface
        - Block.Surface
        - Block.Surface
        - Spaceship.Surfaces.Spaceship
    - Layer
    ...

Таким образом мы сможем располагать и двигать figureLayers.Surface в списке дочек rootLayer-а игнорируя внутреннее устройство блоков, что критично с учётом их динамического появления.

Тут есть технологическая развилка. Блоки можно рисовать сразу в их конечной позиции или в позиции (0,0), после чего двигать их CanvasItemId в соответствующую координату экрана. Исторически я взял первый вариант, а об альтернативе задумался только при написании этого абзаца. Переписывать не стал.

Так выглядит складирование слоёв:

type FigureLayers (disposables) =
    let layersByZIndex = System.Collections.Generic.Dictionary()

    let surface = CanvasItemId.Create(Disposables = disposables)

    let getLayer zIndex =
        layersByZIndex.TryFind zIndex
        |> Option.defaultWith ^ fun () ->
            let layer = CanvasItemId.Create(
                Parent = surface
                , ZIndex = zIndex
                , Disposables = disposables
            )
            layersByZIndex.[zIndex] <- layer
            layer

    member _.GetLayerByZIndex zIndex =
        getLayer zIndex

    member _.GetLayerByPosition position =
        (position : Vector2I).X + position.Y
        |> getLayer

    member _.Surface = surface

Взяв CanvasItemId вместо CanvasItem, мы освободились от многих побочек нод, но при этом потеряли и некоторые преимущества. Например, смерть CanvasItemId-родителя не приводит к смерти CanvasItemId-потомка. Последний продолжит жить и даже может быть усыновлён другим родителем. Это само по себе не плохо, но нам этого пока не надо. Чтобы не забыть об этих Z-слоях, мы сразу интегрируем их в disposables экземпляра, так что удаление произойдёт своевременно.

Код блока структурно идентичен коду изометрической сетки:

type Block (disposables, block : IsometricGrid.Block, relativeHeight, cellOps) =
    let surface = CanvasItemId.Create(Disposables = disposables)

    let mutable isUnderMouse = false

    let redraw () =
        surface.Clear()
        block.Draw cellOps surface relativeHeight (
            if isUnderMouse then SceneColors.unreachableGoal else SceneColors.block
        )

    do redraw ()

    member _.Block = block

    member _.Surface = surface

    member _.IsUnderMouse
        with get () = isUnderMouse
        and set value =
            if value <> isUnderMouse then
                isUnderMouse <- value
                redraw ()

Коллекция блоков выглядит так:

module Blocks =
    open System.Collections.Generic
    open System.Reactive.Disposables

    type Main (disposables : CompositeDisposable, relativeHeight, getLayer, cellOps) =
        let blocks = Dictionary()
        let obstacles = HashSet()
    
        let tryGetBlock block =
            Option.ofPair ^ blocks.TryGetValue block

        let change block =
            let layer =
                (block : IsometricGrid.Block).ZIndex
                |> getLayer
            match tryGetBlock block with
            | None ->
                let disposables = disposables.CreateChild()
                let block = Block(disposables, block, relativeHeight, cellOps)
                block.Surface.SetParent layer
                blocks.[block.Block] <- {| 
                    Model = block
                    Kill = disposables.Dispose
                |}
                obstacles.Add block.Block.Position |> ignore
                Some block
            | Some value ->
                value.Kill()
                blocks.Remove block |> ignore
                obstacles.Remove block.Position |> ignore
                None

        member _.Change block =
            change block

        member _.Contains block =
            blocks.ContainsKey block

        member this.Clear () =
            blocks.Values
            |> List.ofSeq
            |> Seq.iter ^ fun p ->
                this.Change p.Model.Block
                |> ignore

        member _.Obstacles : _ IReadOnlySet = obstacles

        member _.TryGetBlock block = 
            tryGetBlock { Position = block }
            |> Option.map ^ fun p -> p.Model

let figureLayers = FigureLayers(disposables.CreateChild())
let blocks = Blocks.Main(disposables.CreateChild(), 0.5f, figureLayers.GetLayerByZIndex, grid.Cell)

Обычно мы вызываем disposables.CreateChild() только для того, чтобы исключить смерть родителя от рук потомка. В ситуации, когда всё работает как надо, и семейство проживает буквально один и тот же жизненный цикл, дочерний CompositeDisposable не особо нужен. Однако блоки могут быть в любой момент удалены с поля, так что их disposables надо сохранять на быстром наборе. В данном случае мы делаем это неявно через замыкание в Kill, который должен быть вызван перед удалением блока из коллекции.

Отслеживание мыши

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

Для отслеживания тайла под курсором нам нужна нода, которая на каждой итерации _Process проверяет текущие координаты мыши и на их основе вычисляет актуальную клетку поля. Дальше эту клетку необходимо как-то распространить по системе. Звучит как слишком типовая задача, поэтому решим её нестандартным путём. Вместо создания отдельной ноды встроим все процессы в уже существующую. Мы затребуем на вход функцию получения координат мыши, и объявим метод Process, вызов которого будет возложен на владельца экземпляра:

type UnderMouse (disposables, cellOps : IsometricGrid.CellOps, getMousePoint) =
    let underMouse () =
        let point = getMousePoint ()
        let cell = cellOps.OfPixel point
        {|
            Point = point
            Cell = cell
        |}

    let cellChanged = Event<_>()

    let mutable cellUnderMouse, pointUnderMouse =
        let under = underMouse ()
        under.Cell, under.Point

    let surface = CanvasItemId.Create(Disposables = disposables)

    let redraw () =
        surface.Clear ()
        cellOps.Draw false 3f SceneColors.underMouse surface cellUnderMouse

    do redraw ()

    member _.Cell = cellUnderMouse

    member _.CellChanged = cellChanged.Publish

    member _.Surface = surface

    member _.Process () =
        let under = underMouse ()
        pointUnderMouse <- under.Point
        if cellUnderMouse <> under.Cell then
            let previous = cellUnderMouse
            cellUnderMouse <- under.Cell
            cellChanged.Trigger {|
                Previous = previous
                Current = cellUnderMouse
            |}
            redraw ()

Позицию мыши можно взять из main.GetLocalMousePosition(), но этот метод не учитывает трансформацию сетки. В идеале нам следовало бы спрашивать некую ноду внутри rootLayer (если бы тот был нодой способной к усыновлению). Поэтому на практике проще взять матрицу, применённую к rootLayer-у, инвертировать её и перемножить на main.GetLocalMousePosition():

static let gridTransform =
    Transform2D
        .Identity
        .Scaled(2f * Vector2.One)
// Нужно для вычисления позиции курсора в локальных координатах.
static let inversedGridTransform =
    gridTransform.AffineInverse()

...

let underMouse = UnderMouse(
    disposables.CreateChild()
    , grid.Cell
    , fun () -> inversedGridTransform * main.GetLocalMousePosition()
)

Неподотчётный и сверхисполнительный (или ну очень промежуточное заключение)

Тягание матриц на расстояния выглядит неказисто, но без дополнительной инфраструктуры (не сегодня) от этого не уйти. Геттера для rootLayer.Transform нет и не будет, так как RenderingServer.CanvasItemGetTransform не существует в природе. Это глобальная проблема. Если заглянуть в RenderingServerDSL.fs, можно заметить, что все вложенные типы практически не имеют геттеров. Связано это с тем, что соответствующие Query-методы отсутствуют и в RenderingServer. Почти всё его API состоит из Command-методов, то есть из методов, которые что-то меняют, но ничего не говорят. Поэтому установить матрицу трансформации можно, а получить её обратно — нет. Задать предка можно, выяснить — нет. То же самое касается видимости, материалов, ZIndex, модуляции и т. д.

Эта запланированная асимметричность обусловлена тем, что на самом деле мы не выполняем команды, а кладём их в очередь на исполнение. Дальше их когда-то потом подбирает RenderingServer, оптимизирует и выполняет по своему расписанию. Если ничего не сломалось, то сервер проворачивает эти операции почти моментально, что создаёт у нас ложное ощущение синхронности. Однако даже такой вырожденной асинхронности достаточно, чтобы обратная связь была почти полностью исключена.

Если мы знаем, что нода CanvasItem работает как надстройка над RenderingServer, то возникает вопрос, откуда CanvasItem знает свою матрицу трансформации, если RenderingServer её не выдаёт. Ответ прост — он её не знает. Он помнит только последнюю матрицу, которую он сам же загружал в RenderingServer. Если задать эту матрицу в обход CanvasItem (canvasItem.CanvasItemId.SetTransform), то CanvasItem ничего не узнает.

Это не то, чтобы баг, но и не фича. Ситуация аналогична тому, как мы производим ноды с верой в то, что никакой другой игрок не отредактирует их неподобающим образом. То есть в идеале, нам не надо трогать существующие ноды через RenderingServer, потому что для стандартных нод есть стандартное Node-ное API. Всё вмешательство должно сводиться к подсаживанию дочерних CanvasItemId.

Однако зачем нам вообще нужно их подсаживать? На этот вопрос ответить трудно. В рамках этой статьи мы скорее экспериментируем. Классическое «Потому что могу» подкреплённое историческими корнями. За пределами этого текста RenderingServer рядовым пользователям движка не нужен. Особенно F#-истам, так как при помощи лямбд и замыканий они могут сравнительно быстро заспавнить тонну нод с внешне управляемым рисованием на стандартном API. Но RenderingServer может пригодиться, если мы пишем на F# нечто реально большое и сложное.

Как правило, с ростом инфраструктуры возникает ситуация, когда мы полностью вбираем в себя все данные подлежащие изменению, и нода находящаяся между нашей моделью и RenderingServer перестаёт играть самостоятельную роль. Она всё ещё есть и функционирует, но у нас всё чаще возникают вопросы к качеству исполнения и к необходимости преобразования между различными категориальными аппаратами (мир, лишённый DU, не имеет права на жизнь) или, что ещё хуже, временными системами. Особо остро это ощущается в MVU-like фреймворках, где часто хочется отрисовать вьюху в обход не совсем адекватного промежуточного контрола. Однако даже зная о существующих багах и недоработках, среднестатистический контрол просто так не воспроизведёшь, а вот пачку наложенных друг на друга Sprite2D переложить на RenderingServer (с дарованной DSL) может любой хорошо сконфигурированный первокурсник.

От степени раздражения существующими решениями и надо плясать. Если достало — берите RenderingServer. Если пока терпится — не берите. Но в любом случае приглядитесь к устройству этого сервера, ибо он хорош (за исключением оригинального API, оно — полнейший атас). RenderingServer чрезвычайно удобно использовать в событийно ориентированных системах, потому что он со старта выглядит как нечто, что могло родиться в недрах F# инфраструктуры. Я уже почти 8 лет терроризирую своё окружение темой «как мог бы выглядеть UI-фреймворк написанный в функциональном стиле» (прям с нуля, а не в качестве надстройки над ООП-монстром) и впервые я оказываюсь в ситуации, когда эта задача становится технически подъёмной. Конечно, надо ещё подумать над тем, что именно порождает такой оптимизм, и насколько оно переносимо на другие платформы. Однако меня всё ещё не покидает ощущение, что я наконец-то нашёл колпачок от маркера, который до этого почти 8 лет приходилось слюнявить. А так как разбирать, собирать и заправлять маркеры всеми цветами радуги я уже умею, будущее выглядит чрезвычайно плодотворным.

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

Дабы по итогу этой главы у нас получился работающий проект, соберём все описанные типы в кучу:

do main.AddChild ^ GD.Implements._process ^ fun _ _ ->
    underMouse.Process ()

    gridView.IsClosed <- not ^ Input.IsKeyPressed Key.Ctrl

    if  Input.IsActionJustPressed "mouse_left"
        && PathFinder.inMap gridSize underMouse.Cell
    then
        blocks.Change { Position = underMouse.Cell }
        |> Option.iter ^ fun p -> p.IsUnderMouse <- true

    if  Input.IsActionJustPressed "mouse_right"
        && PathFinder.inMap gridSize underMouse.Cell
    then
        camera.Position <- grid.Cell.ToPixelCenter underMouse.Cell * gridTransform

    if Input.IsKeyPressed Key.Escape then
        main.GetTree().Free()

В полной версии песочницы граница сетки задаётся через UI, но так как его время ещё не пришло, я привязался к кнопке ctrl. Пока она зажата, граница не показывается. Этого хватит, чтобы убедиться в работоспособности и «реактивности» CanvasItemId.

Основное управление сводится к двум кнопкам мыши, для которых были заданы соответствующие действия в настройках проекта:

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

По правой кнопке мы перемещаем камеру в выбранную позицию. Тут нам снова нужно сделать корректировку на rootLayer.Transform, и так как мы извлекаем координату из сетки в мир камеры, нам надо использовать исходную матрицу, а не инвертированную.

Выход по Esc пришлось добавить только из-за того, что приложение запускается в полноэкранном режиме, а молодняк не в курсе про Alt + F4. Прям щас попробуйте.

Наконец, надо отработать наведение курсора на уже существующие блоки:

do disposables.Add ^ underMouse.CellChanged.subscribe ^ fun ev ->
    blocks.TryGetBlock ev.Previous
    |> Option.iter ^ fun p -> p.IsUnderMouse <- false
    blocks.TryGetBlock ev.Current
    |> Option.iter ^ fun p -> p.IsUnderMouse <- true

Тут при каждом изменении позиции курсора мы подсвечиваем текущий блок (если таковой имеется) и снимаем подсветку со старого (если таковой имелся). Элементарная операция, но только благодаря тому, что событие CellChanged было изначально определено должным образом, а не как обычно.

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


В следующей главе мы добавим корабль, проекцию пути и анимацию перемещения.


ЗЫ: Товарищи просили объяснить, почему статьи так долго доезжают до Хабра несмотря на то, что содержательная часть уже давно готова. Дело в том, что я начал по-серьёзному забуриваться в геймдев, и он превратился из развлечения во вторую работу с солидной долей задач, никак не относящихся к программированию. Ну и как-то так получилось, что контракта ещё нет, а я уже торчу какой-то пакет задач каждому персонажу в производственной цепочке. Как только сокращу отставание до приемлемых величин (или свалюсь с ангиной), я добью цикл.


НЛО прилетело и оставило здесь промокод для читателей нашего блога: -15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.

Комментарии (0)