惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

C
Check Point Blog
L
LINUX DO - 最新话题
博客园_首页
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
博客园 - 司徒正美
博客园 - Franky
酷 壳 – CoolShell
酷 壳 – CoolShell
WordPress大学
WordPress大学
V
Visual Studio Blog
博客园 - 【当耐特】
小众软件
小众软件
Apple Machine Learning Research
Apple Machine Learning Research
阮一峰的网络日志
阮一峰的网络日志
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
量子位
IT之家
IT之家
H
Hacker News: Front Page
O
OpenAI News
V2EX - 技术
V2EX - 技术
Attack and Defense Labs
Attack and Defense Labs
K
Kaspersky official blog
罗磊的独立博客
博客园 - 聂微东
C
Cyber Attacks, Cyber Crime and Cyber Security
T
Threatpost
N
News and Events Feed by Topic
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Hugging Face - Blog
Hugging Face - Blog
Hacker News: Ask HN
Hacker News: Ask HN
Hacker News - Newest:
Hacker News - Newest: "LLM"
腾讯CDC
P
Privacy International News Feed
V
Vulnerabilities – Threatpost
C
CXSECURITY Database RSS Feed - CXSecurity.com
Help Net Security
Help Net Security
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Spread Privacy
Spread Privacy
H
Help Net Security
S
Secure Thoughts
A
About on SuperTechFans
Google DeepMind News
Google DeepMind News
Cloudbric
Cloudbric
NISL@THU
NISL@THU
云风的 BLOG
云风的 BLOG
Microsoft Security Blog
Microsoft Security Blog
Google DeepMind News
Google DeepMind News
C
CERT Recently Published Vulnerability Notes
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Latest news
Latest news
T
Troy Hunt's Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Шестидесятилетний заключённый и лабораторная крыса. F# на Godot. Часть 14. RenderingServer на полную
kleidemos (F · 2026-04-22 · via Все публикации подряд на Хабре

Уровень сложностиПростой

Время на прочтение16 мин

Охват и читатели1.4K

Туториал

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

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

За остаток цикла мы должны выяснить, как можно строить среднеразмерные игровые сцены на 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.