Всем привет. В ходе этого материала вы узнаете:
Как сделать простой рендер на c# в MonoGame
Что использовать, чтобы вызвать python из c#
Почему пункт 2 - это плохая идея
Как MonoGame игру сбилдить в web/html с помощью его форка KNI
Сегодня нас ждёт долгое путешествие, мы затронем и графику, и веб, и другие извращения - всё ради простейшего геймплея, которого на unity можно было бы добиться за минут 30. Пристёгивайтесь, будет весело.
Неделю назад на выходных решил зачекать MonoGame чисто из интереса. Задача была вызвать python из c# кода - и тут понеслась.
Сразу предупрежду, я не претендую на абсолютную достоверность и правильность использования всех технических терминов и технологий, которых будет много. Вам понадобится знание c# и немного терминала и в принципе этого должно быть достаточно (ну а ещё установленный git, но можно и без этого).
Мне хотелось именно написать прогу, которая бы работала и, как уже было сказано ранее, использовала связку c#+python. Чисто логически, если задача стоит именно проверить связку, то можно написать и самое простое консольное приложение, но тогда бы этой статьи бы не было. Для эксперимента нам больше подойдёт следующая задача:
0. Постановка задачи
Сделаем простую 3д игру
Для графики будем использовать MonoGame. Это фреймворк, а не движок. Не путать! Чуть ниже вы узнаете, как им пользоваться.
Управление wasd, вид от первого лица, вращение камерой мышкой. Я подробно расскажу, как происходит отрисовка в MonoGame, но на управлении останавливаться не стану.
python будет использован для генерации 2д лабиринта, а эти данные мы будем подгружать и рисовать стены. Чуть позднее поясню, как это будет работать конкретно. Но суть вы уловили: нам надо придумать зачем нам нужен python и вот - мы придумали. Да, понимаю, можно сделать было вообще всё на шарпах и не ломать голову, и позднее мы так и сделаем, но сперва слегка извратимся.
Как только игрок прошёл лабиринт - мгновенно генерируется и начинается новый. Финиш лабиринта отображается игроку в виде зелёного вращающегося кубика. Или статичного кубика ? Короче как-то отображается.

Генерация проходимого лабиринта
Вместо того чтобы самим реализовывать алгоритм генерации, подыщем pip пакет, который бы справлялся с задачей. После не��ольшого анализа и сравнения, был выбран mazepy. Почему? Я не могу рассказать, потому что удалил тот чат с перплексити, - остальные варианты меня не устраивали.
Что нам обещает эта либа ? Собственно, генерацию лабиринта (удивительно!). Для особо любопытных, которые не смогут сами найти гитхаб, вот пример как это выглядит:
from mazepy import mazepy grid=mazepy.Grid(10,20) grid=mazepy.getRandomMaze(grid) print(grid)
Тут задаётся размер сетки, после чего либа позволяет нам быстро напечатать результат генерации в консоль без лишнего гемороя через простой print(). Выглядит просто, это то что нам нужно. Однако, формат, в котором хранится сам лабиринт, нам немного неудобен, но об этом попозже.

1. Рендер простой сцены на MonoGame
Что такое MonoGame ? Покажу простейший пример скрипта на нём, который бы рендерил 3д сцену. Никакого движения, никаких поворотов камеры, никаких текстур. Просто статичная сцена. Этого будет достаточно для тех, кто первый раз знакомится с фреймворком.
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; namespace Simple3DFloor { public static class Program { [STAThread] static void Main(string[] args) { using (var game = new Game1()) { game.Run(); } } } public class Game1 : Game { private GraphicsDeviceManager _graphics; private BasicEffect _effect; private VertexBuffer _vertexBuffer; private IndexBuffer _indexBuffer; // Статичные матрицы private Matrix _world = Matrix.Identity; private Matrix _view; private Matrix _projection; public Game1() { _graphics = new GraphicsDeviceManager(this); Content.RootDirectory = "Content"; IsMouseVisible = true; } protected override void Initialize() { // Размер окна _graphics.PreferredBackBufferWidth = 800; _graphics.PreferredBackBufferHeight = 600; _graphics.ApplyChanges(); base.Initialize(); } protected override void LoadContent() { // 1. Вершины плоскости float size = 5f; // от -5 до 5 VertexPosition[] vertices = { new VertexPosition(new Vector3(-size, 0, size)), new VertexPosition(new Vector3( size, 0, size)), new VertexPosition(new Vector3(-size, 0, -size)), new VertexPosition(new Vector3( size, 0, -size)) }; // Индексы для двух треугольников ushort[] indices = { 0, 2, 1, 1, 2, 3 }; // 2. Создаём буферы _vertexBuffer = new VertexBuffer(GraphicsDevice, typeof(VertexPosition), vertices.Length, BufferUsage.WriteOnly); _vertexBuffer.SetData(vertices); _indexBuffer = new IndexBuffer( GraphicsDevice, IndexElementSize.SixteenBits, indices.Length, BufferUsage.WriteOnly); _indexBuffer.SetData(indices); // 3. без освещения и текстур — просто зелёный цвет _effect = new BasicEffect(GraphicsDevice); _effect.LightingEnabled = false; // без освещения _effect.TextureEnabled = false; // без текстур _effect.DiffuseColor = Color.Green.ToVector3(); // 4. Матрицы камеры (фиксированные) _view = Matrix.CreateLookAt( new Vector3(5, 5, 5), // позиция камеры Vector3.Zero, // смотрим в центр Vector3.Up // поворот камеры ); _projection = Matrix.CreatePerspectiveFieldOfView( MathHelper.PiOver4, GraphicsDevice.Viewport.AspectRatio, 0.1f, 100f ); } protected override void Draw(GameTime gameTime) { GraphicsDevice.Clear(Color.CornflowerBlue); // Привязываем буферы GraphicsDevice.SetVertexBuffer(_vertexBuffer); GraphicsDevice.Indices = _indexBuffer; // Настраиваем эффект _effect.World = _world; _effect.View = _view; _effect.Projection = _projection; // Рисуем foreach (EffectPass pass in _effect .CurrentTechnique.Passes) { pass.Apply(); GraphicsDevice.DrawIndexedPrimitives( PrimitiveType.TriangleList, 0, 0, 2); // 2 - кол-во примитивов } base.Draw(gameTime); } } }
Много? Да!
Чтобы это запустить, на текущем этапе вам понадобится sdk (желательно ставьте dotnet 9 или новее) и настроенный csproj, в котором указать импорт пакета MonoGame.Framework.DesktopGL. Этот скрипт рисует зелёную плоскость, но я думаю, вы уже об этом догадались, если внимательно читали скрипт.
MonoGame является преемником фреймворка Microsoft XNA, на котором игры разрабатывались исключительно под Windows и который прекратил своё дальнейшее развитие. Однако сам фреймворк жив по сей день, на нём делали такие крутые 2д игры как Баротравма или Террария.
Что этот скрипт делает - по порядку
Импорты в начале скрипта - я думаю вы понимаете зачем нужны.
Скипну Program и Main.
Далее объявляем класс игры, наследующий от Game — главный класс MonoGame.
Потом 4 поля:
_graphics— доступ к карте._effect— он же шейдер_vertexBuffer— буфер, хранящий вершины треугольников_indexBuffer— буфер, хранящий индексы (порядок соединения вершин в треугольники)
Далее идут 3 матрицы. Они нужны для представления объектов на сцене, вращения точек и проч. Об этом вам должны были объяснять на курсах высшей математики. Позднее объясню подробнее.
Конструктор я, с вашего позволения, скипну, ну а в методе Initialize помимо задания размеров окна ничего интересного не происходит.
В LoadContent устанавливаем вертексы нашей плоскости, которая состоит из 2х треугольников и полностью лежит на полу т.е. её Y=0. Также указываем порядок вертексов. Чтобы вы не запутались, вот как это выглядит:

В системе координат, используемой в MonoGame (и в большинстве графических API, таких как OpenGL), оси ориентированы стандартным образом:
X — направо.
Y — вверх (от плоскости пола в небо).
Z — на картине выше направлен от нас вдоль пола, а камера смотрит вдоль отрицательной оси Z, вдоль отрицательной оси Y и вдоль отрицательной оси X - в центр системы координат.
После того как мы настроили вершины, настраиваем шейдер используя BasicEffect и цвет Color.Green. На этом этапе для простоты не используем освещение.
Далее идут строчки для настройки самой камеры, её поворота:
_view = Matrix.CreateLookAt( cameraPosition: new Vector3(5, 5, 5), cameraTarget: Vector3.Zero, cameraUpVector: Vector3.Up); _projection = Matrix.CreatePerspectiveFieldOfView( fieldOfView: MathHelper.PiOver4, aspectRatio: GraphicsDevice.Viewport.AspectRatio, nearPlaneDistance: 0.1f, farPlaneDistance: 100f);
Когда вы добавите движение персонажем, вам ещё каждый тик придётся вызывать как раз этот CreateLookAt. Тут разжовывать смысла не вижу - каждый метод и его аргументы имеют говорящие названия.
Метод Draw в начале выполняет очистку синим цветом. Устанавливаются буферы, передаются матрицы, применяется эффект и рендерятся примитивы (треугольники).
Теперь немножко теории:
Зачем нужны три матрицы: World, View, Projection?
В 3D-графике объекты проходят несколько преобразований, прежде чем появиться на экране:
World (мировая матрица) — задаёт положение, поворот и масштаб объекта в игровом мире. Если объект стоит в центре и не повёрнут, она единичная (
Matrix.Identity).View (видовая матрица) — представляет камеру. Она определяет, где находится камера и куда она смотрит. По сути, это матрица, которая переводит координаты мира в координаты относительно камеры.
Projection (проекционная матрица) — задаёт, как трёхмерное пространство проецируется на плоский экран: перспектива, либо ортографическая проекция.
Вместе они преобразуют вершины объекта из его локальных координат в экранные.
Что такое IndexBuffer (индексный буфер)?
Представьте, что нам нужно нарисовать прямоугольник из двух треугольников. Вершин всего 4. Без индексного буфера пришлось бы хранить 6 вершин (по 3 на каждый треугольник) с повторением координат. Индексный буфер позволяет экономить память и писать меньше кода.
ОЧЕНЬ ВАЖНО: Чтобы индексы были в правильном порядке. По умолчанию в MonoGame активен CullCounterClockwise, то есть не рендерятся треугольники, вершины которых обходятся по часовой стрелке при просмотре из камеры. Проще говоря, это защита от избыточности: рендерится только одна сторона плоскости. Для целей отладки эту настройку можно отключить. Если вы взгляните на картинку выше правильным обходом, например, будет
0, 2, 1 для первого треугольника (если смотреть из камеры обход против часовой стрелки - таким образом мы увидим треугольник)
1, 2, 3 для второго треугольника (если смотреть из камеры обход также против часовой стрелки)
Если вы поменяете порядок, скажем, передатите индексы 1, 2, 0 для первого треугольника и 3, 2 1 для второго, то когда вы запустите программу, вы увидите только синий экран, которым мы очистили всё пространство когда делали GraphicsDevice.Clear(Color.CornflowerBlue)
Почему в методе Draw каждый раз устанавливается SetVertexBuffer и что такое CurrentTechnique.Passes
SetVertexBuffer нужно вызывать каждый кадр, потому что графическое устройство не хранит состояние буферов постоянно. Вы говорите ему «сейчас используй этот буфер», и он использует его для последующих команд отрисовки. В сложной сцене между вызовами Draw могут рисоваться другие объекты со своими буферами, поэтому перед отрисовкой каждого объекта необходимо заново привязать нужные буферы. Однако для нашего примера вот эти 2 строчки
GraphicsDevice.SetVertexBuffer(_vertexBuffer); GraphicsDevice.Indices = _indexBuffer;
Можно из Draw перенести в LoadContent - Это допустимо, потому что состояние графического устройства сохраняется между кадрами, и никакие другие объекты не меняют привязанные буферы. Графическое устройство сохраняет привязанные ресурсы (вершинный и индексный буферы) до тех пор, пока они не будут явно заменены или сброшены и GraphicsDevice.Clear() не сбрасывает эту привязку.
CurrentTechnique.Passes же относится к механизму шейдеров.
BasicEffectвнутри содержит один или несколько техник (techniques). Техника — это способ рендеринга (например, с текстурами или без).Каждая техника состоит из проходов (passes). Проход — это одна стадия применения шейдера (обычно достаточно одного прохода).
Цикл foreach перебирает все проходы текущей техники. Для каждого прохода вызывается
pass.Apply(), который загружает настройки шейдера в графическую карту, а затем идёт команда отрисовки (DrawIndexedPrimitives). Если много объектов используют один и тот же эффект с одинаковыми параметрами, их можно сгруппировать и рисовать за один вызов (например, с помощью DrawIndexedPrimitives несколько раз подряд без смены эффекта)

Шейдеры это вообще отдельная и очень сложная тема, поэтому особо не буду их трогать, потому что не разбираюсь. Но вам, наверное, безумно интересно, что же мы по итогу смогли отрисовать? Если кратко, то результат ожидаемый:

2. Реализация игровой логики - python.net vs IronPython
Определяемся со структурой проекта
MazeGame/ ├── MazeGame.csproj # Проект с зависимостями ├── Program.cs # Стартовая точка │ ├── Core/ │ ├── Camera.cs # Камера с настройками FOV и инпута │ └── Game1.cs # Загрузка текстур и геймлуп │ ├── Entities/ │ ├── Player.cs # Простой контейнер для игрока │ └── GoalObject.cs # Финиш лабиринта │ ├── Maze/ │ ├── MazeGenerator.cs # Логика по генерации лабиринта │ └── MazeData.cs # Хранение стен и старта с финишем │ ├── Physics/ │ └── CollisionDetector.cs # Проверка столкновения со стеной │ ├── Graphics/ │ └── MazeRenderer.cs # рендер стен+пола+потолка │ └── Content/ └── Textures/ # текстуры + настройка пайплайна
Game1 по сути аггрегирует все имеющиеся типы.
Проходить по каждому файлу отдельно и вставлять сюда его код, пытаясь объяснить происходящее, не стану: из краткого описания и названий классов вам и так должно всё быть понятно. Тем более я позже дам ссылку на github.
Важно понимать, что необходимо было определить интерфейс (т.н. api), по которому данные о структуре лабиринта будут передаваться между частями приложения для 1. рендера и 2. коллизий. Да, в MonoGame нету физического движка, поэтому коллизии надо будет проверять самостоятельно - вы ведь не хотите дать игроку возможность проходить сквозь стены?
Хранение лабиринта
В качестве контракта был выбран следующий способ хранения информации о лабиринте и его стенах. Пусть лабиринт представляет собой квадрат X на X клеток, старт в левом верхнем углу, финиш - в правом нижнем. Тогда хранить лабиринт и передавать его между частями приложения можно вот так:
int[,] grid = [ [1, 1, 1, 1, 1], [1, 2, 0, 1, 1], [1, 1, 0, 1, 1], [1, 1, 0, 3, 1], [1, 1, 1, 1, 1], ];
Вот пример лабиринта 5 на 5. Значения в массиве 1 соответствует стене, 0 - свободной ячейке, старт - 2, финиш - 3. Довольно простой и удобный формат, лабиринт полностью ограждён стенами, чтобы игрок не мог выбраться за его пределы. Хранить эти данные можно как в int[,] так и в int[][] - особо разницы нет. Также такой подход позволяет нам легко определить, в какой ячейке находится игрок в текущий момент, например, с помощью этой функции:
public (int x, int z) WorldToGrid(Vector3 worldPosition) { int gridX = (int)(worldPosition.X / cellSize); int gridZ = (int)(worldPosition.Z / cellSize); return (gridX, gridZ); }
Пример проверки коллизий
Скрипт проверки коллизий не выглядит сложным (в отличие от рендера самого лабиринта, стен и пола) поэтому давайте попробуем разглядеть его поподробнее.
public class CollisionDetector { private readonly MazeData mazeData; private readonly float playerRadius; private const float DefaultPlayerRadius = 0.3f; public CollisionDetector(MazeData mazeData) { this.mazeData = mazeData; playerRadius = DefaultPlayerRadius; } public bool CheckCollision(Vector3 position) => CheckCollision(position, playerRadius); private bool CheckCollision(Vector3 position, float radius) { float cellSize = mazeData.CellSize; var (centerX, centerZ) = mazeData.WorldToGrid(position); for (int dz = -1; dz <= 1; dz++) { for (int dx = -1; dx <= 1; dx++) { int checkX = centerX + dx; int checkZ = centerZ + dz; if (mazeData.IsWall(checkX, checkZ)) { float wallMinX = checkX * cellSize; float wallMaxX = (checkX + 1) * cellSize; float wallMinZ = checkZ * cellSize; float wallMaxZ = (checkZ + 1) * cellSize; float closestX = MathHelper.Clamp(position.X, wallMinX, wallMaxX); float closestZ = MathHelper.Clamp(position.Z, wallMinZ, wallMaxZ); float distX = position.X - closestX; float distZ = position.Z - closestZ; float distanceSquared = distX * distX + distZ * distZ; if (distanceSquared < radius * radius) { return true; } } } } return false; } }
Объект MazeData, который мы принимаем в конструкторе, предоставляет удобные методы для извлечения данных из массива int-ов. Объявленный публичный метод CheckCollision получает на вход позицию игрока и возвращает true если тот упёрся в стену.
cellSize- размер одной клетки лабиринта, в нашем случае 2 условных единицДалее через ранее представленый метод получаем ячейку игрока.
Проверяем каждую соседнюю ячейку
Если соседняя ячейка является стеной (т.е. в массиве массивов стоит значение 1) то получаем все координаты каждой стороны этой ячейки
Через метод
MathHelper.Clamp, который должен быть знаком всем, кто хоть когда-нибудь писал свою игру, получаем ближайшую к игроку координату на поверхности ячейкиВычисляем distanceSquared - квадрат расстояния от найденной точки до самого игрока
Если полученный distanceSquared меньше допустимого радиуса игрока - возвращаем true так как игрок находится "в стене" заполненной ячейки.
Таким образом, проверяя CheckCollision при каждом смещении игрока, мы гарантируем, что игрок никогда не зайдёт внутрь заполненной ячейки или выйдет за пределы лабиринта.

Обратите внимание: мы написали и реализовали простой алгоритм, не использовали корни для точного расстояния потому что О-оптимизация и нам этого хватило без всякого JoltPhysics или Havok.
Как генерируется лабиринт?
Мы рассмотрели контракт хранения и то, как мы данные о лабиринте можем использовать. Давайте вернёмся к тому, как эти данные получить.
Чтобы играть было интересно в идеале надо каждый раз генерировать лабиринт, а не использовать преднастроенные шаблоны/массивы. Лабиринт должен быть проходимым. Как было показано ранее, был выбран mazepy.
Теперь возникает вопрос как вызывать pip пакет из c#? Для этого есть 2 варианта
IronPython - это реализация языка программирования Python, предназначенная для платформы .NET (включая .NET Core и Mono) и полностью написанная на C#. Несложно догадаться, что если это интерпретатор, то его можно использовать для выполнения скрипта python напрямую из c#. В чём минус ? IronPython написан для древнего python2.8 и не поддерживает некоторые pip пакеты по понятным причинам.
python.net - это рантайм-связка. Проще говоря, данная nuget либа вызывает интерпретатор python и обеспечивает связку между объектами python и объектами языка c# позволяя манипулировать объектами python: вызывать функции, обращаться к свойствам, - из скрипта написанном на c#. В чём плюс? Поддерживается последний свежий python И все pip пакеты. Минус? Для его использования нужно, чтобы и на компе разработчика и на компе целевой платформы, где будет запускаться программа, наличествовал сам интерпретатор python, с которым будем связываться. Вплоть до того что в c# вам надо будет указать путь до DLL python, чтобы либа знала откуда подтягивать интерпретатор.
Давайте проверим python.net как я и обещал в названии статьи: будем использовать эту либу для вызова pip пакета для генерации лабиринта. Показываю как это работает:
using System; using System.IO; using Python.Runtime; namespace Maze3D.Maze; public static class MazeGenerator { public static void Initialize() { string pathToPythonHome = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "Python313")); Console.WriteLine("Python home " + pathToPythonHome); string pathToPythonDll = Path.GetFullPath(Path.Combine(pathToPythonHome, @"python313.dll")); Console.WriteLine("Python dll " + pathToPythonDll); Runtime.PythonDLL = pathToPythonDll; PythonEngine.PythonHome = pathToPythonHome; // ..необязательная секция с установкой переменных окружения текущего процесса.. PythonEngine.Initialize(); Console.WriteLine("MazeGenerator Initialized!"); } /// <summary> /// Создать случайный массив стартовая позиция обозначена как 2, финиш - 3. /// </summary> /// <returns>Лабиринт.</returns> public static int[][] Generate() { int[][] mazeArray; using (Py.GIL()) { dynamic mazepy = Py.Import("mazepy.mazepy"); // mazepy package must be installed in side-packages dynamic grid = mazepy.Grid(15, 15); grid = mazepy.getRandomMaze(grid); dynamic startPos = new PyTuple(new PyObject[] { new PyInt(0), new PyInt(0) }); dynamic endPos = new PyTuple(new PyObject[] { new PyInt(14), new PyInt(14) }); mazeArray = MazeToArray(grid, startPos, endPos); } return mazeArray; } private static int[][] MazeToArray(dynamic grid, dynamic start = null, dynamic end = null) { int height = grid.rows * 2 + 1; int width = grid.columns * 2 + 1; int[][] maze = new int[height][]; for (int i = 0; i < height; i++) { maze[i] = new int[width]; for (int j = 0; j < width; j++) maze[i][j] = 1; } foreach (dynamic cell in grid.eachCell()) { int row = cell.row * 2 + 1; int col = cell.column * 2 + 1; maze[row][col] = 0; dynamic east = cell.east; if (cell.linked(east).IsTrue()) maze[row][col + 1] = 0; dynamic south = cell.south; if (cell.linked(south).IsTrue()) maze[row + 1][col] = 0; } if (start != null) { int r = start[0] * 2 + 1; int c = start[1] * 2 + 1; maze[r][c] = 2; } if (end != null) { int r = end[0] * 2 + 1; int c = end[1] * 2 + 1; maze[r][c] = 3; } return maze; } }
На первой строке импортируем либу для организации связки
В методе
Initializeинициализируем runtime питонаВ методе Generate в блоке
using (Py.GIL())стартуем runtime-биндерВнутри биндера импортируем pip пакет примерно так как мы это делали бы в скрипте на python
Вызываем метод генерации ровно так, как это было показано в самом начале когда я демонстрировал работу pip пакета:
dynamic grid = mazepy.Grid(15, 15);Все типы которые мы извлекаем через взаимодействие с биндером должны быть объявлены как dynamic
Пишем и вызываем отдельный метод MazeToArray для генерации формата pip пакета в наш формат хранения данных, такие методы как grid.eachCell() или cell.linked(east) есть и в python и мы их можем вызвать благодаря биндеру, а dynamic превратить в bool можем через методы предоставляемые самим биндером - в нашем случае используем IsTrue
mazepy всегда будет генерировать проходимый лабиринт с началом и концом в указанных точках, наша задача оставалась лишь в том, чтобы генерацию вызвать, а потом преобразовать в массив массивов int-ов, что мы и делаем.
Важно уточнить, что мало того, что python должен быть установлен и вам надо передать пути до него, в самом python также должен быть доступен на глобальном уровне указанный пакет mazepy, что я указал в комментарии.
Если вы взгляните на github то увидите, что для выполнения условия по наличию python на машине пользователя я просто скопировал весь интерпретатор в репозиторий и закоммитил, а в csproj указал, чтобы все файлы рекурсивно копировались в билд. Таким образом, поставляя ваш exe-шник не забудьте убедиться, что рядом с ним лежит и сам интерпретатор или можете использовать другие альтернативные способы по типу попросить игрока указать путь до python при старте работы программы или попытаться установить этот путь самостоятельно автоматически, но это черевато ошибками (например, у игрока может быть установлен python но не установлен mazepy).

Метод генерации лабиринта MazeGenerator.Generate() можно использовать при старте игры, чтобы нарисовать первый лабиринт, и при достижении игроком финиша, чтобы мгновенно нарисовать новый лабиринт, переместив игрока в начальную точку (со значением 2).
3. Инструкция по запуску и результаты работы
Ссылку на github я дал парой абзацев ранее. Давайте перейдём к инструкциям по запуску, которые довольно простые. Откройте терминал и из пустой директории или директории Downloads или ... в принципе любой директории (хоть Windows/System32 ) выполните следующие команды:
git clone https://github.com/Prikalel/ShittyMaze.git cd ShittyMaze dotnet run Maze3D.csproj
Как я уже говорил, у вас должен быть установлен dotnet, если не хотите пользоваться git, то можете скачать напрямую с github страницы, благо, сервис это позволяет.
Вы заметите, что дисковое пространство станет менее свободным примерно на 100МБ, но это потому, что мы затянули весь python, а для сборки и запуска он был скопирован в директорию Debug, где хранятся бинарники и исполняемые файлы проекта. То есть по сути у нас в ShittyMaze после запуска теперь будет 2 питона. (и это если не считать потенциально других версий питона установленных в системе). Понимаю, потеря катастрофическая, но позднее я покажу как это можно исправить.
4. В чём минус?
Теперь, когда всё готово, ответим на 1 вопрос: почему это всё так много весит и можно ли сделать лучше?
Да, как оказывается, можно. И даже больше - можно сделать в браузере! То есть игроку не придётся скачивать python или exe-шник - ему достаточно иметь edge или (в случае linux-а который мы ранее не поддерживали) firefox.
Да, можно с нуля написать всё на js при помощи three.js и получится примерно то же самое, но давайте попробуем использовать свои наработки, ведь у MonoGame есть форк, который как раз решает нашу проблему!
5. Что такое KNI и как устроена доставка в браузер
В этой секции придётся признать, что изначальная идея по установке всего интерпретатора ради одного pip пакета - тупая (и это ещё мягко сказано). Если мы планируем билдить в веб, придётся поискать альтернативы, ведь весь интерпретатор python в веб не запустишь - python.net работает с файлами на машине пользователя, он не будет работать, когда мы сбилдим c# в wasm.
Для генерации лабиринта оказывается всё это время был такой удобный nuget пакет Labyrinthian, написанный на c#, который мы сможем скомпилировать wasm.
Кстати, маленькая справка для тех, кто не знает что это такое:
WebAssembly (wasm) — это открытый стандарт, представляющий собой компактный бинарный формат байт-кода, позволяющий запускать в браузерах высокопроизводительные приложения, написанные на языках типа C++, Rust или C#, со скоростью, близкой к нативной. Wasm компилируется в бинарный код, что намного быстрее интерпретации JavaScript, и который выполняется в изолированной "песочнице" (sandbox), что обеспечивает защиту данных. Небольшой размер бинарных файлов ускоряет загрузку, а кроссплатформенность обеспечивает работу во всех современных браузерах.

KNI (KNI Game Framework) — это кроссплатформенный фреймворк с открытым исходным кодом на языке C#, используемый для разработки 2D/3D-игр. Является оптимизированным форком MonoGame, предлагая высокую производительность, поддержку веб-платформ (Web/WebXR), десктопов и мобильных устройств и ещё кучи чего ещё (даже VR). Несложно догадаться, что если это форк MonoGame, то нашу игру будет несложно под него адаптировать, чем мы скоро займёмся. Но для начала глубже копнём, чтобы понять как это всё работает.
KNI для работы с вебом использует существующий механизм blazor - Это фреймворк для браузерных приложений, написанный на .NET и запускающийся с помощью WebAssembly. Строго говоря, раньше c# не превращался напрямую в wasm. Тогда не было AOT-компиляции. Вместо этого c# компилировали в DLL-шки которые затем загружаются браузером. Эти DLL-шки хранят IL-код, который должен быть выполнен при помощи dotnet.wasm - рантайма Mono, сбилженного для работы в браузере. Mono обрабатывает IL код и умеет его выполнять, а так как он поставляется в виде dotnet.wasm - он выполняет IL код прямо в браузере. Для выполнения кода IL используется так называемый Jiterpreter (в режиме с AOT-компиляцией не используется) — гибридный механизм, целью которого является обработка .NET IL кода. А для отрисовки графики в браузере используется WebGL.

Blazor — это фреймворк, обеспечивающий связь (хостинг) между кодом .NET и браузером. Он позволяет C#-коду (включая Kni и ваши алгоритмы) думать, что он работает на обычном компьютере, в то время как на самом деле он работает внутри JS-движка браузера. Он состоит из:
Среда исполнения (Runtime) - он же модифицированный mono - он же dotnet.wasm
Загрузчик (Bootstrapper) - он же blazor.webassembly.js - Инициализирует WASM-рантайм, Скачивает все .dll, Передает управление в точку входа вашего приложения (
Program.Main).Через JSInterop осуществляет вызов javascript из c# кода (но нам это почти не понадобится)
Песочница (Sandbox) - для осуществления поиска .dll и устранения проблем с вызовами нативного WindowsAPI
На самом деле, если уточнить, то в современном Blazor всё чуть чуть не так - начиная с .NET 6+ есть AOT-компиляция (Ahead-of-Time), при которой C# код действительно компилируется напрямую в WebAssembly без промежуточного IL. В этом режиме нет DLL-шек с IL-кодом, нет Jiterpreter — есть чистый WASM файл. Это активно по умолчанию, поэтому когда вы соберёте проект в билд по инструкции ниже, вы найдёте много wasm файлов в нём, соответствующих сборкам dll.
6. Пример проекта на KNI и инструкции по запуску
Итак, определились, что будем использовать KNI и Labyrinthian вместо MonoGame с python.net.
Опустим долгий рассказ про то, как я всё преобразовывал, посмотрим на структуру проекта на KNI + инструкции по клонированию и сделаем маленький прогон по самым важным файлам. Если что весь код доступен в том же репозитории, но в ветке kni.
ShittyMaze/ ├── PrettyShitty.sln # Visual Studio Solution │ ├── Maze3D/ # остался почти без изменений │ └── Web/ # 🌐 Blazor WebAssembly версия ├── ShittyMaze.Web.csproj # Проект Blazor WASM ├── Program.cs # Точка входа WebAssembly ├── App.razor # Корневой компонент ├── _Imports.razor # Общие using-директивы │ ├── Layout/ # 📐 Макеты │ └── MainLayout.razor # Главный layout страницы │ ├── Pages/ # 📄 Страницы │ ├── Index.razor # Главная страница (разметка) │ └── Index.razor.cs # Code-behind логика │ ├── Content/ # 🎬 MonoGame Content Pipeline │ └── Content.mgcb # Конфигурация контента │ ├── Properties/ # ⚙️ Свойства проекта │ └── launchSettings.json # Настройки запуска │ └── wwwroot/ # 📁 Статические файлы ├── index.html # HTML-хост для WASM ├── favicon.ico # Иконка сайта │ ├── Content/Textures/ # 🖼️ Текстуры │ ├── T_Ceiling_Armstrong_1_BaseColor.bmp/.xnb │ ├── T_Kirpich_7_BaseColor.bmp/.xnb │ └── T_Wood_Floor_2_BaseColor.bmp/.xnb │ ├── css/ # 🎨 Стили │ ├── app.css # Пользовательские стили │ └── bootstrap/ # Bootstrap CSS │ └── js/ # 📜 JavaScript └── decode.min.js # Декодер для WASM
Прошлые файлы почти не трогали, хотя конечно надо было кое-что изменить. Пришлось добавить много новых для инициализации и проч. Например, пришлось изменить Game1.cs так как новый способ управления из браузера подразумевает, что нам нужно отслеживать управление мышкой и из javascript вызывать c# чтобы передавать в KNI данные об инпуте, но это уже вы можете сами посмотреть.
Новый MazeGenerator
Самое интересное это всё таки MazeGenerator, который в новом исполнении выглядит так::
using System; using System.Linq; using Labyrinthian; namespace Maze3D.Maze; public static class MazeGenerator { public static int[][] Generate() { return Generate(10, 10); } public static int[][] Generate(int width, int height) { var maze = new OrthogonalMaze(height, width); // используем Labyrinthian var generator = new PrimGeneration(maze); generator.Generate(); int outputHeight = height * 2 + 1; int outputWidth = width * 2 + 1; int[][] mazeArray = new int[outputHeight][]; for (int i = 0; i < outputHeight; i++) { mazeArray[i] = new int[outputWidth]; for (int j = 0; j < outputWidth; j++) mazeArray[i][j] = 1; } int cols = width; foreach (var cell in maze.Cells) { if (cell == null) continue; int cellIndex = cell.Index; int cellRow = cellIndex / cols; int cellCol = cellIndex % cols; int row = cellRow * 2 + 1; int col = cellCol * 2 + 1; mazeArray[row][col] = 0; var neighbors = cell.Neighbors; foreach (var neighbor in neighbors) { if (neighbor == null) continue; // Проверка что ячейки соединены if (maze.AreCellsConnected(cell, neighbor)) { int neighborIndex = neighbor.Index; int neighborRow = neighborIndex / cols; int neighborCol = neighborIndex % cols; int neighborOutputRow = neighborRow * 2 + 1; int neighborOutputCol = neighborCol * 2 + 1; int wallRow = (row + neighborOutputRow) / 2; int wallCol = (col + neighborOutputCol) / 2; if (wallRow >= 0 && wallRow < outputHeight && wallCol >= 0 && wallCol < outputWidth) { mazeArray[wallRow][wallCol] = 0; } } } } int startRow = 1; int startCol = 1; mazeArray[startRow][startCol] = 2; int endRow = (height - 1) * 2 + 1; int endCol = (width - 1) * 2 + 1; mazeArray[endRow][endCol] = 3; return mazeArray; } }
Тут опять же свой формат с этим AreCellsConnected, но как и в предыдущей версии - мы преобразуем одно в другое для удобства.
На что стоит обратить внимание и чего вы не узнаете, пока сами не попробуете сделать 3д игру на KNI:
Максимальный размер текстуры в KNI в отличие от MonoGame - 2048*2048
Используйте стандартный загрузчик контента вместо фетча картинок по URL, потому что иначе могут быть проблемы с форматом файла (png/jpeg вот это всё - некоторые могут не поддерживаться)
Буфер с индексами должен быть не int[] а short[]
Какие новые возможности при этом у вас имеются:
Вы можете задать свой splash-экран на время загрузки контента и инициализации webGL
Вы можете использовать nuget пакеты, но будьте внимательны с тем, что они делают. Нам повезло что Labyrinthian может быть скомпилирован и выполнен в wasm, специфичные пакеты по типу тех что работают с WindowsApi или проч. могут не работать по понятным причинам.
Вы можете обращаться к javascript и наоборот при помощи стандартных инструментов blazor
Запуск
Для начала надо выполнить checkout на другую ветку и pull если вы скачивали проект при помощи git. Альтернативно, если вы скачивали zip через интерфейс github, то надо скачать ещё раз только ветку под названием kni. Запуск:
dotnet publish .\Web\ShittyMaze.Web.csproj -c Release -f net8.0 /p:TargetPlatform=WebGL cd .\Web\bin\Release\net8.0\publish\wwwroot\ python -m http.server 5012
Последняя команда отвечает за запуск сервера на localhost на порте 5012. Чисто технически вам необязательно для этого использовать python может подойти dotnet serve, который можно установить следующим образом:
dotnet tool install --global dotnet-serve
Геймплей, как вы понимаете, не сильно будет отличаться, так что выкладывать тот же видос не буду. Однако, вы можете сами убедиться в том, что у нас всё получилось, если зайдёте на https://prikalel.github.io/ShittyMaze/ (лучше не делать это с телефона). Запуск может быть долгим, так как надо скачать текстуры.
7. Выводы
Основной "прикол" KNI и MonoGame в том, что они являются обёрткой над openGL и графическим драйвером, предоставляя вам единый интерфейс для графики, но при этом ещё и pipeline, который преобразует артефакты (текстуры, звуки, даже шрифты) в универсальный свой формат xnb, который затем можно импортировать уже когда игра собрана (на всех целевых платформах).
Строго говоря, эти фреймворки не совсем подходят для разработки 3д игр, потому что у вас нету 3д редактора и ещё кучи других фич, которые придётся писать самому.
Отвечу на, возможно, возникший у вас вопрос - для чего пытаться совместить в одном проекте два совершенно разных языка из двух совершенно разных платформ? Ну, задачи бывают разные и хорошо, когда есть несколько способов решения, однако, я бы выбрал grpc или http чем вызывать вот так - напрямую из кода. В этой же статье вся работа была проделана в целях эксперимента и какие можно сделать выводы:
KNI очень похож на MonoGame и предоставляет новые крутые фишки по типу сборки в web/html.
Если выбирать между тем, как вызвать python код из c#, лучше подойдёт что-то простое типа http соединения, чем копировать весь интерпретатор несколько раз. Так или иначе python.net имеет право на существование и оказался довольно удобным с точки зрения написания кода но не с точки зрения доставки билда конечному потребителю.
Спасибо, что дочитали до конца. Мы узнали много нового и интересного, посмотрели на фреймворки для создания собственных игр и сделали несложную игру, в которую можно погонять в браузере. Исходный код проекта выложен в открытый доступ, кстати там же можно посмотреть как выгрядит html билд в плане какие файлы в него попадают (ветка kni-build).
Полезные ссылки
KNI github страница - https://github.com/kniEngine/kni
pip пакет mazepy - https://pypi.org/project/mazepy/
Labyrinthian nuget пакет - https://www.nuget.org/packages/Labyrinthian
Статья отсюда по теме - Pythonnet. Как запустить C# код из Python
Документация MonoGame - https://docs.monogame.net/
(*) Накидайте звёзд - Другой проект моего коллеги в котором тоже используется c# и графика
(*) Может быть полезно - Большая подборка полезных штук для разработчиков - фреймворки, движки, утилиты, программы
Всем спасибо за внимание.
