Форум программистов, компьютерный форум, киберфорум
GameUnited
Войти
Регистрация
Восстановить пароль
Блоги Сообщество Поиск Заказать работу  

Пишем первую игру на MonoGame

Запись от GameUnited размещена 17.08.2025 в 21:05
Показов 5456 Комментарии 0

Нажмите на изображение для увеличения
Название: production_images_ee3dee56-c6e6-425a-99d3-8fc4ca14d009.jpg
Просмотров: 460
Размер:	274.7 Кб
ID:	11058
Признаюсь честно, когда я решил наконец-то воплотить свою давнюю мечту и попробовать силы в разработке игр, голова пошла кругом от обилия доступных инструментов. Unity, Unreal Engine, Godot - все эти имена мелькали в каждой статье про геймдев. Кажется, только ленивый не советовал начинать именно с них. Но что-то внутри противилось этому мейнстримному подходу. Возможно, моя инженерная натура требовала копнуть глубже, понять, как всё устроено "под капотом". И тут на горизонте появился MonoGame.

Я давно работаю с C# и .NET экосистемой, поэтому первое, что меня зацепило в MonoGame - возможность использовать знакомые инструменты и язык программирования. Не нужно было переучиваться на C++ или осваивать какой-нибудь специфический скриптовый язык - просто открыл Visual Studio и погрузился в привычную среду. Это сразу снизило порог входа и сэкономило кучу времени.

А ещё, если честно, мне никогда не нравились тяжеловесные движки, где под простую 2D-игру приходится тащить гигабайты ресурсов и разбираться в запутанном интерфейсе. MonoGame предлагал другой подход - минимализм и контроль. Это фреймворк, а не движок. Он дает базовые инструменты и не навязывает своей архитектуры. Хочешь создать свою систему компонентов? Пожалуйста! Нужен специфический пайплайн обработки ресурсов? Никто не мешает! Эта гибкость подкупила меня с первого знакомства. Ещё один аргумент в пользу MonoGame - кроссплатформенность. Я хотел, чтобы мою первую игру можно было запустить и на Windows, и на Mac, и даже на мобильных устройствах без тотального переписывания кода. MonoGame предлагает именно такую возможность - пишешь один раз, компилируешь под разные платформы. Да, есть нюансы и специфичные моменты для каждой платформы, но базовая архитектура остается неизменной.

"Хорошо, но разве с Unity не проще?" - спросите вы. Возможно. Но простота эта обманчива. Когда ты используешь инструмент, скрывающий от тебя внутренние механизмы, рано или поздно упрешься в потолок его возможностей. А если не понимаешь, как всё работает внутри, решить нетривиальную проблему будет гораздо сложнее. MonoGame вынуждает тебя самому реализовывать игровой цикл, систему рендеринга, физику - и это прекрасно, если хочешь действительно понять, как устроены игры.

Конечно, у монеты есть и обратная сторона. MonoGame не даёт встроенных редакторов уровней, готовых решений для анимации или системы частиц - всё это придётся либо писать самому, либо искать сторонние библиотеки. Но, как по мне, это справедливая цена за свободу и понимание процессов.

Альтернативы MonoGame: сравнительный анализ с Unity и Godot



Когда речь заходит о выборе инструмента для разработки игр, нельзя просто взять и сказать: "вот это - самое лучшее, берите не думая". Нет универсальных решений, и MonoGame - не исключение. Давайте честно сравним его с двумя наиболее популярными альтернативами - Unity и Godot, чтобы вы могли сами решить, что подходит именно для вашего проекта.

Unity: промышленный стандарт с высоким порогом входа



Unity безусловно остаётся одним из самых популярных движков в индустрии. И на то есть причины. Этот монстр предлагает впечатляющий набор инструментов "из коробки": визуальный редактор сцен, мощную физику, готовую анимационную систему, визуальное программирование и множество ассетов в магазине. По сути, в Unity можно создать игру, не написав ни строчки кода (хотя, конечно, без программирования далеко не уедешь). Но такая мощь имеет свою цену. Unity требует довольно мощного железа для комфортной работы. Кроме того, хоть базовая версия и бесплатна, как только ваша игра начнёт приносить доход выше определённого порога, придётся раскошелиться на лицензию. А ещё, Unity использует собственный скриптовый язык C#, который хоть и похож на тот, что используется в .NET, имеет свои особености и ограничения.

Главное отличие от MonoGame - философия. Unity - это полноценный движок с визуальным редактором, где многое уже решено за вас. MonoGame же - это фреймворк, который даёт базовые инструменты и говорит: "Теперь твори сам". В Unity вы работаете В редакторе, в MonoGame вы создаёте всё В коде. Сам я помню, как несколько раз пытался освоить Unity, и каждый раз терялся в обилии окошек, меню и настроек. Для человека, привыкшего к чистому коду, это было настоящим испытанием. Казалось бы, делаю всё по туториалу, а результат каждый раз разный - то камера не туда смотрит, то коллайдеры не работают. И самое обидное - не всегда понятно, почему.

Godot: восходящая звезда с открытым кодом



Godot в последние годы набирает бешеную популярность, и это неудивительно. Этот движок предлагает многие возможности Unity, но при этом полностью бесплатен, имеет открытый исходный код и гораздо меньше весит. Философия Godot ближе к Unity, чем к MonoGame - это тоже визуальный редактор с богатым набором встроенных функций. Одно из главных преимуществ Godot - его удобная нодовая система, где каждый объект игры является узлом в дереве сцены. Это интуитивно понятная концепция, которая упрощает организацию проекта. Кроме того, Godot предлагает собственный язык программирования GDScript, похожий на Python, что делает порог входа ещё ниже.

Но у Godot есть и недостатки. Несмотря на стремительное развитие, сообщество и экосистема всё ещё меньше, чем у Unity. Это значит меньше туториалов, меньше готовых ассетов, меньше ответов на StackOverflow. К тому же, если вы привыкли к C# и .NET, переход на GDScript может быть болезненным (хотя в Godot есть и поддержка C#, но она не так хорошо отлажена). Я экспериментировал с Godot пару месяцев и был приятно удивлен его простотой. Однако, когда дело дошло до реализации специфичных фишек, упёрся в те же проблемы, что и с Unity - приходится подстраиваться под логику движка, а не наоборот.

Когда выбирать MonoGame, а когда - альтернативы?



Если вы - новичок в геймдеве и хотите быстро получить видимый результат, без глубокого погружения в детали - Unity или Godot будут лучшим выбором. Эти движки позволяют сосредоточиться на геймплее и дизайне, а не на низкоуровневых аспектах. MonoGame же подойдет вам, если:
  1. Вы уже знаете C# и хотите использовать весь потенциал .NET,
  2. Вам важно понимать, как всё работает "под капотом",
  3. Вы создаёте относительно простую 2D-игру (хотя 3D тоже возможно),
  4. Вам нужен полный контроль над каждым аспектом игры,
  5. Вы цените минимализм и не хотите тащить гигабайты движка ради простого проекта.

Лично для меня решающим фактором стал именно образовательный аспект. Я хотел не просто создать игру, но и понять, как игры устроены в принципе. И здесь MonoGame с его "голым" подходом оказался неоценим.

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

XNA или Monogame? Чем Monogame лучше XNA?
Добрый день! Занимаюсь разработкой игр на xna на vcs2010. Вроде все хорошо идет, но тут наткнулся...

MonoGame Android project
Всем доброго времени суток, недавно мне понадобилось портировать WP7 игру на Android, я много...

Модели в MonoGame
Друзья, есть кроссплатформенная имплементация XNA под названием MonoGame. Но я никак не могу...

справочник по monoGame
помогите найти какой нибудь справочник или книжку по MonoGame на русском, а то ни как не могу найти


Подготовка рабочего окружения



Итак, решение принято — делаем игру на MonoGame. Но с чего начать? Первым делом нужно настроить рабочее окружение, и здесь нет ничего сложного, особенно если вы уже работали с .NET. Давайте разберем весь процесс шаг за шагом, чтобы даже новички смогли быстро стартовать.

Установка Visual Studio и .NET SDK



Прежде всего, нам понадобится среда разработки. MonoGame поддерживает все популярные IDE для .NET разработки: Visual Studio, Visual Studio Code и JetBrains Rider. Я предпочитаю классическую Visual Studio, хотя в последнее время всё чаще посматриваю в сторону Rider — он работает шустрее и имеет много полезных фишек для геймдева. Если у вас еще нет Visual Studio, скачайте Community Edition — она бесплатна для индивидуальных разработчиков и небольших команд. При установке обязательно выберите рабочую нагрузку ".NET desktop development" — без неё не получится нормально работать с .NET приложениями. Важный момент: MonoGame требует .NET 8 SDK или новее. Даже если вы устанавливали Visual Studio недавно, не факт, что у вас есть нужная версия SDK. Проверить это просто — откройте командную строку и выполните:

Bash
1
dotnet --version
Если версия ниже 8.0, придется установить актуальный SDK с официального сайта .NET. Вобще, лично я предпочитаю устанавливать SDK отдельно от Visual Studio — так гораздо проще управлять версиями и быстрее обновляться.

Установка шаблонов MonoGame



Теперь самое интересное — устанавливаем шаблоны проектов MonoGame. Это делается одной простой командой:

Bash
1
dotnet new install MonoGame.Templates.CSharp
Выполните её в командной строке, и через пару секунд вы получите доступ ко всем шаблонам MonoGame. Успешная установка выглядит примерно так:

C#
1
2
3
4
5
6
7
8
9
10
11
12
Success: MonoGame.Templates.CSharp::3.8.3 installed the following templates:
Template Name                Short Name         Language  Tags
---------------------------  -----------------  --------  -------------------------------------------------------------
MonoGame 2D StartKit                          mg2dstartkit                [C#]        MonoGame/Games/Mobile/Android/iOS/Desktop/Windows/Linux/macOS
MonoGame Android Application                  mgandroid                   [C#]        MonoGame/Games/Mobile/Android
MonoGame Blank 2D StartKit                    mgblank2dstartkit           [C#]        MonoGame/Games/Mobile/Android/iOS/Desktop/Windows/Linux/macOS
MonoGame Content Pipeline Extension           mgpipeline                  [C#]        MonoGame/Games/Extensions
MonoGame Cross-Platform Desktop Application   mgdesktopgl                 [C#]        MonoGame/Games/Desktop/Windows/Linux/macOS
MonoGame Game Library                         mglib                       [C#]        MonoGame/Games/Library
MonoGame iOS Application                      mgios                       [C#]        MonoGame/Games/Mobile/iOS
MonoGame Shared Library Project               mgshared                    [C#]        MonoGame/Games/Library
MonoGame Windows Desktop Application          mgwindowsdx                 [C#]        MonoGame/Games/Desktop/Windows/Linux/macOS
Теперь у нас есть выбор между различными типами проектов. Для начала лучше выбрать "MonoGame Cross-Platform Desktop Application" (mgdesktopgl) — он позволяет создавать приложения, которые запускаются на Windows, Linux и macOS благодаря использованию OpenGL.

Создание первого проекта



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

Bash
1
2
3
4
5
6
7
8
9
10
11
12
# Создаем папку для проекта
mkdir MyFirstGame
cd MyFirstGame
 
# Создаем решение
dotnet new sln
 
# Создаем проект MonoGame
dotnet new mgdesktopgl -o GameProject
 
# Добавляем проект в решение
dotnet sln add GameProject/GameProject.csproj
Если вы привыкли работать через Visual Studio, то можно сделать всё через интерфейс:
1. File -> New -> Project
2. В поиске вводите "MonoGame"
3. Выбираете "MonoGame Cross-Platform Desktop Application"
4. Указываете имя и расположение проекта

Вот и всё! Проект создан и готов к работе. Но прежде чем бросаться писать код, давайте разберемся с тем, что у нас получилось.

Анализ структуры проекта



Если вы откроете файл проекта (csproj), то увидите что-то вроде этого:

XML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RollForward>Major</RollForward>
    <PublishReadyToRun>false</PublishReadyToRun>
    <TieredCompilation>false</TieredCompilation>
  </PropertyGroup>
 
  <PropertyGroup>
    <ApplicationManifest>app.manifest</ApplicationManifest>
    <ApplicationIcon>Icon.ico</ApplicationIcon>
  </PropertyGroup>
 
  <ItemGroup>
    <None Remove="Icon.ico" />
    <None Remove="Icon.bmp" />
  </ItemGroup>
 
  <ItemGroup>
    <EmbeddedResource Include="Icon.ico">
      <LogicalName>Icon.ico</LogicalName>
    </EmbeddedResource>
    <EmbeddedResource Include="Icon.bmp">
      <LogicalName>Icon.bmp</LogicalName>
    </EmbeddedResource>
  </ItemGroup>
 
  <ItemGroup>
    <PackageReference Include="MonoGame.Framework.DesktopGL" Version="3.8.*" />
    <PackageReference Include="MonoGame.Content.Builder.Task" Version="3.8.*" />
  </ItemGroup>
 
  <Target Name="RestoreDotnetTools" BeforeTargets="Restore">
    <Message Text="Restoring dotnet tools" Importance="High" />
    <Exec Command="dotnet tool restore" />
  </Target>
</Project>
Это стандартный проект .NET, но с несколькими особеностями:

1. Ссылки на два основных NuGet-пакета:
- MonoGame.Framework.DesktopGL — сам фреймворк MonoGame с поддержкой OpenGL.
- MonoGame.Content.Builder.Task — инструменты для сборки контента (графики, звуков и т.д.).
2. Цель MSBuild RestoreDotnetTools, которая автоматически восстанавливает необходимые инструменты при восстановлении пакетов..

В корне проекта вы также найдете файл .config/dotnet-tools.json, который описывает используемые инструменты:

JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
  "version": 1,
  "isRoot": true,
  "tools": {
    "dotnet-mgcb": {
      "version": "3.8.3",
      "commands": [ "mgcb" ]
    },
    "dotnet-mgcb-editor": {
      "version": "3.8.3",
      "commands": [ "mgcb-editor" ]
    },
    "dotnet-mgcb-editor-linux": {
      "version": "3.8.3",
      "commands": [ "mgcb-editor-linux" ]
    },
    "dotnet-mgcb-editor-windows": {
      "version": "3.8.3",
      "commands": [ "mgcb-editor-windows" ]
    },
    "dotnet-mgcb-editor-mac": {
      "version": "3.8.3",
      "commands": [ "mgcb-editor-mac" ]
    }
  }
}
Эти инструменты нужны для работы с контентом игры. MGCB (MonoGame Content Builder) — это специальный компилятор, который оптимизирует ресурсы для использования в игре. А MGCB Editor — графический интерфейс для управления этими ресурсами.

Первый запуск



Чтобы убедиться, что всё работает как надо, запустите проект. Это можно сделать через IDE (нажав F5 в Visual Studio) или из командной строки:

Bash
1
2
cd GameProject
dotnet run
Если всё настроено правильно, вы увидите окно с голубым фоном — это ваша первая "игра" на MonoGame! Цвет фона, кстати, не случаен — "Cornflower Blue" (васильковый синий) это традиционный цвет, используемый в тестовых проектах XNA и MonoGame, что-то вроде "Hello, World!" в мире игровой разработки. Если окно не появилось или возникли ошибки — не паникуйте. Обычно проблемы связаны с несовместимостью версий или отсутствием нужных зависимостей. Проверьте, что установили актуальную версию .NET SDK и правильные шаблоны MonoGame. Я, например, однажды столкнулся с проблемой, когда проект отказывался запускаться из-за того, что какие-то системные библиотеки OpenGL отсутствовали на моем ноутбуке. Решилось простой переустановкой драйверов видеокарты — такие вот неочевидные взаимосвязи.

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

Архитектура игрового цикла



Если вы решили создавать игры на MonoGame, то вам придётся хорошенько разобраться с тем, как устроен игровой цикл. Это сердце любой игры, пламенный мотор, который заставляет всё работать слаженно. И знаете что? Это не так сложно, как может показаться на первый взгляд. Давайте взглянем на код, который генерируется шаблоном MonoGame. В файле Game1.cs вы найдёте класс, наследующийся от Game, примерно такого вида:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class Game1 : Game
{
    private GraphicsDeviceManager _graphics;
    private SpriteBatch _spriteBatch;
 
    public Game1()
    {
        _graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsMouseVisible = true;
    }
 
    protected override void Initialize()
    {
        // TODO: Add your initialization logic here
 
        base.Initialize();
    }
 
    protected override void LoadContent()
    {
        _spriteBatch = new SpriteBatch(GraphicsDevice);
 
        // TODO: use this.Content to load your game content here
    }
 
    protected override void Update(GameTime gameTime)
    {
        if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
            Exit();
 
        // TODO: Add your update logic here
 
        base.Update(gameTime);
    }
 
    protected override void Draw(GameTime gameTime)
    {
        GraphicsDevice.Clear(Color.CornflowerBlue);
 
        // TODO: Add your drawing code here
 
        base.Draw(gameTime);
    }
}
Это и есть скелет игрового цикла MonoGame. Давайте разберем каждый метод и поймём, что происходит под капотом.

Метод Initialize: подготовка сцены



Метод Initialize вызывается однократно при запуске игры, до загрузки контента. Здесь обычно инициализируются переменные, создаются объекты, которые не требуют загруженных ресурсов. Например, можно настроить размер окна:

C#
1
2
3
4
5
6
7
8
protected override void Initialize()
{
    _graphics.PreferredBackBufferWidth = 1280;
    _graphics.PreferredBackBufferHeight = 720;
    _graphics.ApplyChanges();
    
    base.Initialize();
}
Важно помнить, что вызов base.Initialize() в конце метода обязателен! Без него не сработают многие внутренние механизмы MonoGame. Я как-то потратил целый вечер, пытаясь понять, почему моя игра не отображается, пока не заметил, что случайно закоментировал эту строку. Такие вот мелочи могут стоить часов отладки.

LoadContent: загрузка ресурсов



После инициализации вызывается метод LoadContent. Именно здесь должна происходить загрузка всех ресурсов игры: текстур, моделей, шрифтов, звуков. MonoGame предоставляет специальный объект ContentManager, который доступен через свойство Content:

C#
1
2
3
4
5
6
7
8
9
protected override void LoadContent()
{
    _spriteBatch = new SpriteBatch(GraphicsDevice);
    
    _playerTexture = Content.Load<Texture2D>("Player");
    _backgroundTexture = Content.Load<Texture2D>("Background");
    _font = Content.Load<SpriteFont>("GameFont");
    _jumpSound = Content.Load<SoundEffect>("Jump");
}
ContentManager автоматически ищет ресурсы в директории, указанной в конструкторе (по умолчанию это "Content"). Важно, что MonoGame не работает напрямую с обычными PNG или MP3 файлами - всё сначала должно быть обработано через специальный конвейер контента (Content Pipeline), о чём мы поговорим позже.

Отдельно стоит упомянуть, что ContentManager кеширует загруженные ресурсы. Если вы попытаетесь загрузить один и тот же ресурс дважды, вторая загрузка просто вернёт ссылку на уже существующий объект в памяти.

UnloadContent: освобождение ресурсов



Метод UnloadContent вызывается при завершении игры и предназначен для освобождения ресурсов, которые не управляются ContentManager. В большинстве случаев вам не придётся его переопределять, так как ContentManager сам освободит всё, что было загружено через него, когда вы вызовете Content.Unload() или Content.Dispose(). Однако, если вы создаёте ресурсы вручную (например, через new Texture2D()), то должны сами позаботиться об их освобождении:

C#
1
2
3
4
5
6
7
protected override void UnloadContent()
{
    // Освобождаем ресурсы, созданные вручную
    _manuallyCreatedTexture?.Dispose();
    
    base.UnloadContent();
}

Update: обновление состояния игры



Метод Update - это место, где происходит вся игровая логика. Он вызывается с фиксированной частотой (по умолчанию 60 раз в секунду) и получает параметр gameTime, содержащий информацию о времени.
Именно здесь вы обрабатываете ввод пользователя, обновляете позиции объектов, проверяете коллизии и т.д.:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected override void Update(GameTime gameTime)
{
    // Выход из игры при нажатии Escape
    if (Keyboard.GetState().IsKeyDown(Keys.Escape))
        Exit();
    
    // Вычисляем время, прошедшее с предыдущего кадра (в секундах)
    float deltaTime = (float)gameTime.ElapsedGameTime.TotalSeconds;
    
    // Обновляем позицию игрока
    _playerPosition += _playerVelocity * deltaTime;
    
    // Проверяем коллизии
    if (CheckCollision(_playerPosition, _obstacles))
    {
        _playerVelocity = Vector2.Zero;
        _gameState = GameState.GameOver;
    }
    
    base.Update(gameTime);
}
Обратите внимание на использование deltaTime (времени между кадрами) для плавного движения. Это критически важно для того, чтобы игра работала с одинаковой скоростью на разных компьютерах независимо от их производительности. Я в своих проектах обычно разбиваю Update на более мелкие методы, чтобы код оставался чистым и понятным:

C#
1
2
3
4
5
6
7
8
9
10
11
12
protected override void Update(GameTime gameTime)
{
    float deltaTime = (float)gameTime.ElapsedGameTime.TotalSeconds;
    
    HandleInput(deltaTime);
    UpdatePhysics(deltaTime);
    CheckCollisions();
    UpdateAnimations(deltaTime);
    UpdateParticles(deltaTime);
    
    base.Update(gameTime);
}

Draw: отрисовка кадра



Метод Draw отвечает исключительно за отрисовку текущего состояния игры. Он вызывается сразу после Update и тоже получает параметр gameTime. Важно понимать, что в Draw не должно быть никакой игровой логики - только рисование!

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected override void Draw(GameTime gameTime)
{
    GraphicsDevice.Clear(Color.CornflowerBlue);
    
    _spriteBatch.Begin();
    
    // Рисуем фон
    _spriteBatch.Draw(_backgroundTexture, Vector2.Zero, Color.White);
    
    // Рисуем игрока
    _spriteBatch.Draw(_playerTexture, _playerPosition, null, Color.White, 
                       _playerRotation, _playerOrigin, 1.0f, SpriteEffects.None, 0);
    
    // Рисуем очки
    _spriteBatch.DrawString(_font, $"Score: {_score}", new Vector2(10, 10), Color.White);
    
    _spriteBatch.End();
    
    base.Draw(gameTime);
}
Метод _spriteBatch.Begin() начинает процесс отрисовки спрайтов, а _spriteBatch.End() завершает его и отправляет всё на экран. Между ними можно вызывать методы Draw и DrawString сколько угодно раз. Такой подход позволяет оптимизировать отрисовку, группируя похожие операции вместе.

Управление временем и частотой кадров



Одна из ключевых задач в разработке игр — это обеспечение стабильной производительности независимо от мощности устройства. MonoGame предлагает несколько механизмов для управления временем и частотой кадров. По умолчанию MonoGame пытается обновлять и отрисовывать игру с частотой 60 кадров в секунду. Но вы можете изменить это поведение с помощью свойства TargetElapsedTime:

C#
1
2
// Установка частоты обновления 30 кадров в секунду
this.TargetElapsedTime = TimeSpan.FromSeconds(1 / 30.0);
Кроме того, есть свойство IsFixedTimeStep, которое определяет, будет ли игра работать с фиксированным временным шагом:

C#
1
2
// Отключение фиксированного временного шага
this.IsFixedTimeStep = false;
Когда IsFixedTimeStep установлен в true (по умолчанию), MonoGame пытается вызывать метод Update через равные промежутки времени, определяемые свойством TargetElapsedTime. Если игра работает медленно и не успевает выполнить обновление за отведенное время, MonoGame может вызвать Update несколько раз подряд, прежде чем выполнить Draw. Я обычно оставляю фиксированный временной шаг для логики, но иногда отключаю его для особых эффектов или визуальных компонентов, которые должны быть максимально плавными.

Синхронизация кадров (V-Sync)



Ещё один важный аспект — вертикальная синхронизация (V-Sync). Она синхронизирует отрисовку кадров с частотой обновления монитора, чтобы избежать визуальных артефактов вроде "разрыва" изображения.

C#
1
2
_graphics.SynchronizeWithVerticalRetrace = true; // Включить V-Sync
_graphics.ApplyChanges();
Если вы отключите V-Sync, игра может работать быстрее частоты обновления монитора, но могут появиться визуальные артефакты. В большинстве случаев лучше оставить V-Sync включенным.

Оптимизация производительности игрового цикла



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

1. Минимизируйте выделение памяти в методах Update и Draw. Создание новых объектов в каждом кадре может вызвать частую сборку мусора, что приводит к заметным задержкам.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
// Плохо: выделение памяти в каждом кадре
protected override void Update(GameTime gameTime)
{
    Vector2 direction = new Vector2(1, 0); // Новый объект в каждом кадре
    _position += direction * 5 * (float)gameTime.ElapsedGameTime.TotalSeconds;
}
 
// Хорошо: переиспользование объектов
private readonly Vector2 _direction = new Vector2(1, 0);
protected override void Update(GameTime gameTime)
{
    _position += _direction * 5 * (float)gameTime.ElapsedGameTime.TotalSeconds;
}
2. Используйте пулинг объектов для часто создаваемых и уничтожаемых сущностей, например, пуль или эффектов частиц.
3. Применяйте отсечение видимости (culling). Нет смысла обновлять или отрисовывать объекты, которые находятся за пределами экрана.

C#
1
2
3
4
5
6
// Только если объект видим на экране
if (IsVisible(_objectPosition, _objectSize))
{
    UpdateObject(gameTime);
    DrawObject();
}
4. Оптимизируйте SpriteBatch. Группируйте спрайты с одинаковыми параметрами отрисовки.

C#
1
2
3
4
5
6
7
8
9
10
11
12
// Лучше так
_spriteBatch.Begin(SpriteSortMode.Texture);
// Рисуем все спрайты с одинаковыми параметрами
_spriteBatch.End();
 
// Чем так
_spriteBatch.Begin();
// Рисуем один спрайт
_spriteBatch.End();
_spriteBatch.Begin();
// Рисуем другой спрайт
_spriteBatch.End();
5. Используйте многопоточность с осторожностью. MonoGame, как и XNA, не является полностью потокобезопасным. Логика игры должна выполняться в основном потоке, но тяжелые вычисления можно вынести в отдельные потоки.

Я однажды попытался распараллелить обновление сотен объектов, и игра начала случайно вылетать. Оказалось, что доступ к некоторым ресурсам MonoGame из других потоков вызывал гонки данных. Пришлось переделать на более тонкую архитектуру с очередями задач.

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

Создание простейшей игровой механики



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

Загрузка текстур и работа с ContentManager



Первое, с чем придётся столкнуться при создании игры — загрузка контента. MonoGame использует специальную систему управления ресурсами через ContentManager, который я уже кратко упоминал. Но прежде чем мы сможем загрузить какой-либо контент, нам нужно его создать и подготовить через ContentPipeline. Вот пошаговая инструкция:

1. Сначала нужно добавить файлы ресурсов в проект. Для этого найдите в проекте файл Content.mgcb и откройте его в редакторе MGCB:

Bash
1
2
3
4
# Из командной строки
dotnet mgcb-editor Content/Content.mgcb
 
# Или просто дважды щелкните по файлу в Visual Studio
2. В открывшемся редакторе добавьте новый ресурс: правой кнопкой по корню проекта -> Add -> Existing Item... и выберите, например, PNG-изображение для вашего игрока.
3. После добавления выберите подходящий процессор (для PNG это обычно TextureProcessor) и нажмите Build, чтобы скомпилировать ресурс.
После этой подготовительной работы вы можете загрузить текстуру в вашем коде:

C#
1
2
// В методе LoadContent
_playerTexture = Content.Load<Texture2D>("Player");
Обратите внимание, что при загрузке мы указываем только имя ресурса без расширения. ContentManager сам определит, какой файл нужно загрузить.

В моей практике я столкнулся с одной неочевидной проблемой: если вы изменили исходный файл (например, отредактировали PNG), не забудьте пересобрать контент через MGCB, иначе изменения не попадут в игру. Я как-то потратил час, пытаясь понять, почему моя текстура не обновляется, хотя я ее явно поменял.

Отображение спрайтов на экране



Загрузив текстуру, мы можем нарисовать её на экране с помощью SpriteBatch. Типичный паттерн отрисовки выглядит так:

C#
1
2
3
4
5
6
7
8
9
10
protected override void Draw(GameTime gameTime)
{
    GraphicsDevice.Clear(Color.CornflowerBlue);
    
    _spriteBatch.Begin();
    _spriteBatch.Draw(_playerTexture, _playerPosition, Color.White);
    _spriteBatch.End();
    
    base.Draw(gameTime);
}
Метод Draw класса SpriteBatch имеет множество перегрузок, позволяющих контролировать различные параметры отрисовки:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Базовая отрисовка в позиции
_spriteBatch.Draw(_texture, _position, Color.White);
 
// Отрисовка с масштабом, вращением и точкой привязки
_spriteBatch.Draw(
    _texture,             // Текстура для отрисовки
    _position,            // Позиция на экране
    null,                 // Прямоугольник для вырезания части текстуры (null = вся текстура)
    Color.White,          // Цветовой фильтр (белый = без изменений)
    _rotation,            // Угол поворота в радианах
    _origin,              // Точка привязки/центр вращения (0,0 = верхний левый угол)
    _scale,               // Масштаб (1.0f = оригинальный размер)
    SpriteEffects.None,   // Эффекты отражения
    0                     // Глубина отрисовки (для сортировки)
);
Важно понимать, что координаты в MonoGame начинаются с верхнего левого угла экрана: X увеличивается вправо, Y увеличивается вниз. Это может быть неинтуитивно, если вы привыкли к математической системе координат.

Управление жизненным циклом объектов



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

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Player
{
    private Texture2D _texture;
    public Vector2 Position { get; set; }
    public float Rotation { get; set; }
    public Vector2 Velocity { get; set; }
    
    public Player(Texture2D texture, Vector2 position)
    {
        _texture = texture;
        Position = position;
        Velocity = Vector2.Zero;
    }
    
    public void Update(GameTime gameTime)
    {
        float deltaTime = (float)gameTime.ElapsedGameTime.TotalSeconds;
        Position += Velocity * deltaTime;
    }
    
    public void Draw(SpriteBatch spriteBatch)
    {
        spriteBatch.Draw(_texture, Position, null, Color.White, Rotation, 
                          new Vector2(_texture.Width / 2, _texture.Height / 2), 
                          1.0f, SpriteEffects.None, 0);
    }
}
Теперь наш игровой код становится чище:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private Player _player;
 
protected override void LoadContent()
{
    _spriteBatch = new SpriteBatch(GraphicsDevice);
    Texture2D playerTexture = Content.Load<Texture2D>("Player");
    _player = new Player(playerTexture, new Vector2(100, 100));
}
 
protected override void Update(GameTime gameTime)
{
    if (Keyboard.GetState().IsKeyDown(Keys.Escape))
        Exit();
    
    _player.Update(gameTime);
    
    base.Update(gameTime);
}
 
protected override void Draw(GameTime gameTime)
{
    GraphicsDevice.Clear(Color.CornflowerBlue);
    
    _spriteBatch.Begin();
    _player.Draw(_spriteBatch);
    _spriteBatch.End();
    
    base.Draw(gameTime);
}
Для более сложных игр вы можете реализовать компонентную систему, подобную ECS (Entity Component System), но для начала подойдет и такой простой подход.

Обработка пользовательского ввода



MonoGame предоставляет несколько классов для обработки ввода: KeyboardState, MouseState, GamePadState. Вот как можно реализовать базовое управление для нашего игрока:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void HandleInput(KeyboardState keyboardState, float deltaTime)
{
    // Сбрасываем скорость
    Velocity = Vector2.Zero;
    
    // Проверяем нажатия клавиш и устанавливаем скорость
    if (keyboardState.IsKeyDown(Keys.W) || keyboardState.IsKeyDown(Keys.Up))
        Velocity.Y = -200; // Движение вверх
        
    if (keyboardState.IsKeyDown(Keys.S) || keyboardState.IsKeyDown(Keys.Down))
        Velocity.Y = 200; // Движение вниз
        
    if (keyboardState.IsKeyDown(Keys.A) || keyboardState.IsKeyDown(Keys.Left))
        Velocity.X = -200; // Движение влево
        
    if (keyboardState.IsKeyDown(Keys.D) || keyboardState.IsKeyDown(Keys.Right))
        Velocity.X = 200; // Движение вправо
        
    // Нормализуем вектор, чтобы диагональное движение не было быстрее
    if (Velocity != Vector2.Zero)
        Velocity = Vector2.Normalize(Velocity) * 200;
}
Затем вызываем этот метод в Update:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
protected override void Update(GameTime gameTime)
{
    float deltaTime = (float)gameTime.ElapsedGameTime.TotalSeconds;
    KeyboardState keyboardState = Keyboard.GetState();
    
    if (keyboardState.IsKeyDown(Keys.Escape))
        Exit();
    
    _player.HandleInput(keyboardState, deltaTime);
    _player.Update(gameTime);
    
    base.Update(gameTime);
}
Для обработки мыши процесс аналогичный:

C#
1
2
3
4
5
6
7
8
9
10
11
12
MouseState mouseState = Mouse.GetState();
Vector2 mousePosition = new Vector2(mouseState.X, mouseState.Y);
 
// Поворачиваем игрока в сторону мыши
Vector2 direction = mousePosition - _player.Position;
_player.Rotation = (float)Math.Atan2(direction.Y, direction.X);
 
// Проверяем нажатия кнопок мыши
if (mouseState.LeftButton == ButtonState.Pressed)
{
    // Выстрел или другое действие
}
Важный момент: не используйте сравнение нажатий между кадрами напрямую (типа if (currentState.IsKeyDown() && !previousState.IsKeyDown())), так как вы можете пропустить быстрые нажатия. Вместо этого сохраняйте предыдущее состояние и сравнивайте:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private KeyboardState _previousKeyboardState;
 
protected override void Update(GameTime gameTime)
{
    KeyboardState currentKeyboardState = Keyboard.GetState();
    
    // Проверка, была ли клавиша нажата в этом кадре
    if (currentKeyboardState.IsKeyDown(Keys.Space) && 
        !_previousKeyboardState.IsKeyDown(Keys.Space))
    {
        // Действие при нажатии пробела
    }
    
    _previousKeyboardState = currentKeyboardState;
    
    base.Update(gameTime);
}
Такой подход гарантирует, что вы отловите все нажатия, даже если они происходят между кадрами.

Работа со звуком и аудиоэффектами



Звук - важная составляющая любой игры. MonoGame поддерживает два типа аудио: SoundEffect для коротких звуков (выстрелы, прыжки, взрывы) и Song для фоновой музыки. Чтобы добавить звуки в игру, сначала нужно загрузить их через Content Pipeline, как и текстуры. В редакторе MGCB добавьте звуковой файл (WAV, MP3) и выберите подходящий процессор.
Затем загрузите и воспроизведите звук в коде:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Загрузка звука
SoundEffect jumpSound = Content.Load<SoundEffect>("Jump");
SoundEffectInstance jumpSoundInstance = jumpSound.CreateInstance();
 
// Воспроизведение один раз
jumpSound.Play();
 
// Или с настройками громкости, панорамы и высоты тона
jumpSound.Play(0.5f, 0.0f, 0.0f); // Громкость 50%, центр, нормальная высота
 
// Для более сложного управления используйте экземпляр
jumpSoundInstance.Volume = 0.7f;
jumpSoundInstance.IsLooped = false;
jumpSoundInstance.Play();
 
// Остановка звука
jumpSoundInstance.Stop();
Для фоновой музыки процесс немного отличается:

C#
1
2
3
4
5
6
7
8
9
10
// Загрузка музыки
Song backgroundMusic = Content.Load<Song>("Music/Background");
 
// Воспроизведение
MediaPlayer.Play(backgroundMusic);
MediaPlayer.IsRepeating = true;
MediaPlayer.Volume = 0.5f;
 
// Остановка
MediaPlayer.Stop();
Один нюанс, с которым я столкнулся: если вы хотите воспроизводить несколько экземпляров одного звука одновременно (например, несколько выстрелов), используйте метод SoundEffect.Play() для каждого воспроизведения, а не один экземпляр SoundEffectInstance. Иначе звуки будут перебивать друг друга.

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

Создание анимаций и покадровое воспроизведение спрайтов



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

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public class Animation
{
    private Texture2D _spritesheet;
    private readonly Rectangle[] _frames;
    private int _currentFrame;
    private float _timePerFrame;
    private float _timer;
    public bool IsLooping { get; set; } = true;
    public bool IsFinished { get; private set; }
 
    public Animation(Texture2D spritesheet, int frameCount, int frameWidth, int frameHeight, float fps)
    {
        _spritesheet = spritesheet;
        _frames = new Rectangle[frameCount];
        _timePerFrame = 1f / fps;
        
        // Создаем прямоугольники для каждого кадра
        for (int i = 0; i < frameCount; i++)
        {
            _frames[i] = new Rectangle(i * frameWidth, 0, frameWidth, frameHeight);
        }
    }
 
    public void Update(float deltaTime)
    {
        if (IsFinished) return;
        
        _timer += deltaTime;
        
        if (_timer >= _timePerFrame)
        {
            _currentFrame++;
            _timer = 0;
            
            if (_currentFrame >= _frames.Length)
            {
                if (IsLooping)
                    _currentFrame = 0;
                else
                {
                    _currentFrame = _frames.Length - 1;
                    IsFinished = true;
                }
            }
        }
    }
 
    public void Draw(SpriteBatch spriteBatch, Vector2 position, Color color)
    {
        spriteBatch.Draw(_spritesheet, position, _frames[_currentFrame], color);
    }
    
    public void Reset()
    {
        _currentFrame = 0;
        _timer = 0;
        IsFinished = false;
    }
}
Использовать этот класс просто:

C#
1
2
3
4
5
6
7
8
9
10
11
// Загружаем спрайтшит
Texture2D runSpritesheet = Content.Load<Texture2D>("RunAnimation");
 
// Создаем анимацию бега (8 кадров по 64x64 пикселя, 12 FPS)
Animation runAnimation = new Animation(runSpritesheet, 8, 64, 64, 12);
 
// В методе Update
runAnimation.Update((float)gameTime.ElapsedGameTime.TotalSeconds);
 
// В методе Draw
runAnimation.Draw(_spriteBatch, _playerPosition, Color.White);
Особенно удобной эта система становится, когда у вас несколько анимаций для одного персонажа. Например, бег, прыжок, атака и так далее. В этом случае я обычно создаю словарь анимаций:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private Dictionary<string, Animation> _animations = new Dictionary<string, Animation>();
private string _currentAnimation = "idle";
 
// В LoadContent
_animations["idle"] = new Animation(Content.Load<Texture2D>("IdleAnimation"), 4, 64, 64, 8);
_animations["run"] = new Animation(Content.Load<Texture2D>("RunAnimation"), 8, 64, 64, 12);
_animations["jump"] = new Animation(Content.Load<Texture2D>("JumpAnimation"), 6, 64, 64, 10);
_animations["jump"].IsLooping = false;  // Прыжок проиграется только один раз
 
// В Update
_animations[_currentAnimation].Update(deltaTime);
 
// Для смены анимации
public void SetAnimation(string animName)
{
    if (_currentAnimation != animName)
    {
        _currentAnimation = animName;
        _animations[_currentAnimation].Reset();
    }
}
Кстати, многие используют для анимаций отдельные кадры вместо спрайтшитов. В этом случае придётся изменить код, чтобы он работал с массивом текстур, а не с прямоугольниками внутри одной текстуры. Оба подхода имеют право на жизнь.

Создание игровых состояний и переходов между сценами



Когда ваша игра становится сложнее, чем "двигайся и стреляй", вам понадобится система состояний для управления разными экранами: главное меню, уровень, пауза, экран смерти и так далее. Простейший подход — это перечисление и switch-case:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public enum GameState
{
    MainMenu,
    Playing,
    Paused,
    GameOver
}
 
private GameState _currentState = GameState.MainMenu;
 
protected override void Update(GameTime gameTime)
{
    switch (_currentState)
    {
        case GameState.MainMenu:
            UpdateMainMenu(gameTime);
            break;
        case GameState.Playing:
            UpdateGameplay(gameTime);
            break;
        case GameState.Paused:
            UpdatePaused(gameTime);
            break;
        case GameState.GameOver:
            UpdateGameOver(gameTime);
            break;
    }
    
    base.Update(gameTime);
}
 
protected override void Draw(GameTime gameTime)
{
    GraphicsDevice.Clear(Color.CornflowerBlue);
    
    _spriteBatch.Begin();
    
    switch (_currentState)
    {
        case GameState.MainMenu:
            DrawMainMenu();
            break;
        case GameState.Playing:
            DrawGameplay();
            break;
        case GameState.Paused:
            DrawGameplay();  // Рисуем игру на заднем фоне
            DrawPaused();    // Рисуем паузу поверх
            break;
        case GameState.GameOver:
            DrawGameplay();  // Показываем где умер
            DrawGameOver();
            break;
    }
    
    _spriteBatch.End();
    
    base.Draw(gameTime);
}
Для более сложных игр я рекомендую использовать систему экранов, где каждый экран (состояние) инкапсулирован в отдельный класс. Например:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public abstract class GameScreen
{
    public bool IsActive { get; set; } = true;
    
    public abstract void Update(GameTime gameTime);
    public abstract void Draw(SpriteBatch spriteBatch);
    
    public virtual void OnEnter() { }
    public virtual void OnExit() { }
}
 
public class ScreenManager
{
    private Stack<GameScreen> _screens = new Stack<GameScreen>();
    
    public void PushScreen(GameScreen screen)
    {
        if (_screens.Count > 0)
            _screens.Peek().IsActive = false;
            
        _screens.Push(screen);
        screen.OnEnter();
    }
    
    public void PopScreen()
    {
        if (_screens.Count > 0)
        {
            GameScreen screen = _screens.Pop();
            screen.OnExit();
            
            if (_screens.Count > 0)
                _screens.Peek().IsActive = true;
        }
    }
    
    public void Update(GameTime gameTime)
    {
        if (_screens.Count > 0 && _screens.Peek().IsActive)
            _screens.Peek().Update(gameTime);
    }
    
    public void Draw(SpriteBatch spriteBatch)
    {
        // Рисуем все экраны, начиная с нижнего
        foreach (var screen in _screens.Reverse())
        {
            screen.Draw(spriteBatch);
        }
    }
}
Такой подход позволяет легко наслаивать экраны (например, меню паузы поверх игры) и управлять переходами между ними.

Работа с физикой: простые коллизии без внешних библиотек



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

C#
1
2
3
4
5
6
7
public bool Intersects(Rectangle rectA, Rectangle rectB)
{
    return rectA.X < rectB.X + rectB.Width &&
           rectA.X + rectA.Width > rectB.X &&
           rectA.Y < rectB.Y + rectB.Height &&
           rectA.Y + rectA.Height > rectB.Y;
}
Для круглых объектов можно использовать проверку расстояния:

C#
1
2
3
4
5
public bool Intersects(Vector2 centerA, float radiusA, Vector2 centerB, float radiusB)
{
    float distance = Vector2.Distance(centerA, centerB);
    return distance < radiusA + radiusB;
}
Для игрока и врагов можно создать метод получения "хитбокса":

C#
1
2
3
4
5
6
7
8
9
public Rectangle GetBounds()
{
    return new Rectangle(
        (int)(Position.X - Origin.X * Scale),
        (int)(Position.Y - Origin.Y * Scale),
        (int)(Texture.Width * Scale),
        (int)(Texture.Height * Scale)
    );
}
А потом проверять пересечения:

C#
1
2
3
4
if (_player.GetBounds().Intersects(_enemy.GetBounds()))
{
    // Произошло столкновение!
}
Для платформеров часто требуется более сложная физика с реакцией на столкновения. Вот простой пример управления гравитацией и коллизиями с платформами:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Константы физики
private const float Gravity = 800f;
private const float JumpForce = -400f;
private bool _isGrounded = false;
 
// В Update
public void Update(GameTime gameTime)
{
    float deltaTime = (float)gameTime.ElapsedGameTime.TotalSeconds;
    
    // Применяем гравитацию
    Velocity.Y += Gravity * deltaTime;
    
    // Обновляем позицию
    Position += Velocity * deltaTime;
    
    // Проверяем столкновения с землей
    _isGrounded = false;
    foreach (var platform in _platforms)
    {
        if (GetBounds().Intersects(platform.GetBounds()))
        {
            // Мы столкнулись с платформой
            // Восстанавливаем позицию
            Position = new Vector2(Position.X, platform.Position.Y - Texture.Height);
            
            // Останавливаем падение
            Velocity.Y = 0;
            _isGrounded = true;
        }
    }
    
    // Прыжок по нажатию пробела
    if (_isGrounded && Keyboard.GetState().IsKeyDown(Keys.Space))
    {
        Velocity.Y = JumpForce;
        _isGrounded = false;
    }
}
Конечно, это очень простая реализация. Для более сложных физических взаимодействий я рекомендую использовать специализированные библиотеки вроде Farseer Physics (Box2D для MonoGame) или написать более продвинутую систему самостоятельно.

Между прочим, я как-то реализовал упрощенную версию физики для своей игры-головоломки, и столкнулся с забавным багом: объекты начинали хаотично "прыгать", когда пересекались сразу с несколькими платформами. Оказалось, что я неправильно обрабатывал коррекцию позиции при множественных столкновениях. Физика в играх — это всегда веселый челлендж!

Игра "Поймай квадрат"



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

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



Прежде чем писать код, давайте спроектируем структуру нашей игры. Я предпочитаю выделять отдельные классы для разных игровых сущностей:
  • Game1 — основной класс игры,
  • Player — класс, отвечающий за платформу игрока,
  • Square — класс для падающих квадратов,
  • ScoreManager — класс для подсчёта и отображения очков,
  • ParticleSystem — для визуальных эффектов при ловле квадрата.
Такая структура позволит нам легко поддерживать и расширять код в будущем.

Реализация базовой структуры



Начнем с создания нового проекта MonoGame (я использую шаблон для OpenGL):

Bash
1
dotnet new mgdesktopgl -o CatchTheSquare
Теперь модифицируем класс Game1, чтобы он соответствовал нашим потребностям:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class Game1 : Game
{
    private GraphicsDeviceManager _graphics;
    private SpriteBatch _spriteBatch;
    
    private Player _player;
    private List<Square> _squares;
    private ScoreManager _scoreManager;
    private ParticleSystem _particleSystem;
    
    private Texture2D _squareTexture;
    private Texture2D _playerTexture;
    private SpriteFont _font;
    private SoundEffect _catchSound;
    private SoundEffect _missSound;
    
    private Random _random;
    private float _squareSpawnTimer;
    private float _squareSpawnDelay = 2.0f; // Начальная задержка 2 секунды
    private int _missedSquares;
    private int _maxMissedSquares = 5;
    private GameState _gameState = GameState.Playing;
    
    public enum GameState
    {
        Playing,
        GameOver
    }
    
    public Game1()
    {
        _graphics = new GraphicsDeviceManager(this);
        Content.RootDirectory = "Content";
        IsMouseVisible = true;
        
        _graphics.PreferredBackBufferWidth = 800;
        _graphics.PreferredBackBufferHeight = 600;
    }
 
    // Остальные методы добавим ниже
}

Создание текстур и ресурсов



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

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
protected override void LoadContent()
{
    _spriteBatch = new SpriteBatch(GraphicsDevice);
    
    // Создаем текстуру квадрата 32x32 пикселя
    _squareTexture = new Texture2D(GraphicsDevice, 32, 32);
    Color[] data = new Color[32 * 32];
    for (int i = 0; i < data.Length; i++)
        data[i] = Color.White; // Белый цвет, который мы будем тонировать при отрисовке
    _squareTexture.SetData(data);
    
    // Создаем текстуру платформы 128x16 пикселей
    _playerTexture = new Texture2D(GraphicsDevice, 128, 16);
    data = new Color[128 * 16];
    for (int i = 0; i < data.Length; i++)
        data[i] = Color.White;
    _playerTexture.SetData(data);
    
    // Загружаем шрифт (предполагается, что он уже добавлен в контент)
    _font = Content.Load<SpriteFont>("Font");
    
    // Загружаем звуки (предполагается, что они уже добавлены в контент)
    _catchSound = Content.Load<SoundEffect>("Catch");
    _missSound = Content.Load<SoundEffect>("Miss");
    
    // Инициализируем игровые объекты
    _random = new Random();
    _squares = new List<Square>();
    _player = new Player(_playerTexture, new Vector2(_graphics.PreferredBackBufferWidth / 2, _graphics.PreferredBackBufferHeight - 30));
    _scoreManager = new ScoreManager(_font);
    _particleSystem = new ParticleSystem(GraphicsDevice);
}

Реализация игровых объектов



Теперь создадим класс для платформы игрока:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Player
{
    public Vector2 Position { get; set; }
    public Texture2D Texture { get; private set; }
    public float Speed { get; set; } = 400f;
    public Rectangle Bounds => new Rectangle((int)Position.X - Texture.Width / 2, (int)Position.Y - Texture.Height / 2, Texture.Width, Texture.Height);
    
    public Player(Texture2D texture, Vector2 position)
    {
        Texture = texture;
        Position = position;
    }
    
    public void Update(GameTime gameTime, int screenWidth)
    {
        float deltaTime = (float)gameTime.ElapsedGameTime.TotalSeconds;
        KeyboardState keyboardState = Keyboard.GetState();
        
        // Перемещение влево/вправо
        if (keyboardState.IsKeyDown(Keys.Left) || keyboardState.IsKeyDown(Keys.A))
            Position.X -= Speed * deltaTime;
        if (keyboardState.IsKeyDown(Keys.Right) || keyboardState.IsKeyDown(Keys.D))
            Position.X += Speed * deltaTime;
        
        // Ограничиваем движение экраном
        Position.X = Math.Clamp(Position.X, Texture.Width / 2, screenWidth - Texture.Width / 2);
    }
    
    public void Draw(SpriteBatch spriteBatch)
    {
        spriteBatch.Draw(Texture, Position, null, Color.LightBlue, 0f, new Vector2(Texture.Width / 2, Texture.Height / 2), 1f, SpriteEffects.None, 0f);
    }
}
И класс для падающих квадратов:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Square
{
    public Vector2 Position { get; set; }
    public Vector2 Velocity { get; set; }
    public Color Color { get; set; }
    public Texture2D Texture { get; private set; }
    public bool IsActive { get; set; } = true;
    public Rectangle Bounds => new Rectangle((int)Position.X - Texture.Width / 2, (int)Position.Y - Texture.Height / 2, Texture.Width, Texture.Height);
    
    public Square(Texture2D texture, Vector2 position, Vector2 velocity, Color color)
    {
        Texture = texture;
        Position = position;
        Velocity = velocity;
        Color = color;
    }
    
    public void Update(GameTime gameTime)
    {
        float deltaTime = (float)gameTime.ElapsedGameTime.TotalSeconds;
        Position += Velocity * deltaTime;
    }
    
    public void Draw(SpriteBatch spriteBatch)
    {
        if (IsActive)
            spriteBatch.Draw(Texture, Position, null, Color, 0f, new Vector2(Texture.Width / 2, Texture.Height / 2), 1f, SpriteEffects.None, 0f);
    }
}
Дополним систему подсчета очков:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ScoreManager
{
    public int Score { get; private set; }
    public int Level => Math.Max(1, Score / 10 + 1); // Каждые 10 очков - новый уровень
    private SpriteFont _font;
    
    public ScoreManager(SpriteFont font)
    {
        _font = font;
        Score = 0;
    }
    
    public void AddScore(int points)
    {
        Score += points;
    }
    
    public void Draw(SpriteBatch spriteBatch, int screenWidth)
    {
        string scoreText = $"Очки: {Score}";
        string levelText = $"Уровень: {Level}";
        
        spriteBatch.DrawString(_font, scoreText, new Vector2(10, 10), Color.White);
        spriteBatch.DrawString(_font, levelText, new Vector2(screenWidth - _font.MeasureString(levelText).X - 10, 10), Color.White);
    }
}
И простую систему частиц для визуальных эффектов:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
public class ParticleSystem
{
    private struct Particle
    {
        public Vector2 Position;
        public Vector2 Velocity;
        public Color Color;
        public float Size;
        public float Lifetime;
        public float Age;
    }
    
    private List<Particle> _particles = new List<Particle>();
    private Texture2D _particleTexture;
    private Random _random = new Random();
    
    public ParticleSystem(GraphicsDevice graphicsDevice)
    {
        // Создаем одно-пиксельную текстуру для частиц
        _particleTexture = new Texture2D(graphicsDevice, 1, 1);
        _particleTexture.SetData(new[] { Color.White });
    }
    
    public void AddParticles(Vector2 position, Color color, int count)
    {
        for (int i = 0; i < count; i++)
        {
            _particles.Add(new Particle
            {
                Position = position,
                Velocity = new Vector2(
                    (float)(_random.NextDouble() * 2 - 1) * 100,
                    (float)(_random.NextDouble() * 2 - 1) * 100
                ),
                Color = color,
                Size = (float)_random.NextDouble() * 5 + 1,
                Lifetime = (float)_random.NextDouble() * 0.5f + 0.5f,
                Age = 0
            });
        }
    }
    
    public void Update(GameTime gameTime)
    {
        float deltaTime = (float)gameTime.ElapsedGameTime.TotalSeconds;
        
        for (int i = _particles.Count - 1; i >= 0; i--)
        {
            Particle particle = _particles[i];
            particle.Age += deltaTime;
            
            if (particle.Age >= particle.Lifetime)
            {
                _particles.RemoveAt(i);
                continue;
            }
            
            particle.Position += particle.Velocity * deltaTime;
            particle.Velocity *= 0.97f; // Небольшое замедление
            _particles[i] = particle;
        }
    }
    
    public void Draw(SpriteBatch spriteBatch)
    {
        foreach (var particle in _particles)
        {
            float alpha = 1f - (particle.Age / particle.Lifetime);
            Color color = particle.Color * alpha;
            
            spriteBatch.Draw(
                _particleTexture,
                particle.Position,
                null,
                color,
                0f,
                Vector2.Zero,
                particle.Size,
                SpriteEffects.None,
                0f
            );
        }
    }
}

Реализация основной игровой логики



Теперь соберем всё вместе в методах Update и Draw класса Game1:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
protected override void Update(GameTime gameTime)
{
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
        Exit();
 
    float deltaTime = (float)gameTime.ElapsedGameTime.TotalSeconds;
    
    if (_gameState == GameState.Playing)
    {
        // Обновляем игрока
        _player.Update(gameTime, _graphics.PreferredBackBufferWidth);
        
        // Обновляем существующие квадраты
        for (int i = _squares.Count - 1; i >= 0; i--)
        {
            _squares[i].Update(gameTime);
            
            // Проверка коллизий с игроком
            if (_squares[i].Bounds.Intersects(_player.Bounds) && _squares[i].IsActive)
            {
                _squares[i].IsActive = false;
                _scoreManager.AddScore(1);
                _catchSound.Play(0.5f, 0, 0);
                _particleSystem.AddParticles(_squares[i].Position, _squares[i].Color, 20);
                _squares.RemoveAt(i);
                continue;
            }
            
            // Проверка, не упал ли квадрат за пределы экрана
            if (_squares[i].Position.Y > _graphics.PreferredBackBufferHeight + 20)
            {
                _squares.RemoveAt(i);
                _missedSquares++;
                _missSound.Play(0.5f, 0, 0);
                
                if (_missedSquares >= _maxMissedSquares)
                    _gameState = GameState.GameOver;
            }
        }
        
        // Генерация новых квадратов
        _squareSpawnTimer += deltaTime;
        if (_squareSpawnTimer >= _squareSpawnDelay)
        {
            _squareSpawnTimer = 0;
            
            // С каждым уровнем квадраты падают быстрее и чаще
            _squareSpawnDelay = Math.Max(0.5f, 2.0f - (_scoreManager.Level - 1) * 0.1f);
            
            // Создаем квадрат в случайной позиции с случайным цветом
            float x = (float)_random.NextDouble() * (_graphics.PreferredBackBufferWidth - 50) + 25;
            Vector2 position = new Vector2(x, -20);
            Vector2 velocity = new Vector2(0, 100 + _scoreManager.Level * 20);
            
            Color color = new Color(
                (float)_random.NextDouble(),
                (float)_random.NextDouble(),
                (float)_random.NextDouble()
            );
            
            _squares.Add(new Square(_squareTexture, position, velocity, color));
        }
    }
    else if (_gameState == GameState.GameOver)
    {
        // Проверка нажатия для рестарта
        if (Keyboard.GetState().IsKeyDown(Keys.R))
            RestartGame();
    }
    
    // Обновляем систему частиц в любом случае
    _particleSystem.Update(gameTime);
    
    base.Update(gameTime);
}
 
protected override void Draw(GameTime gameTime)
{
    GraphicsDevice.Clear(Color.Black);
    
    _spriteBatch.Begin();
    
    // Рисуем игровые объекты
    foreach (var square in _squares)
        square.Draw(_spriteBatch);
    
    _player.Draw(_spriteBatch);
    _particleSystem.Draw(_spriteBatch);
    
    // Рисуем интерфейс
    _scoreManager.Draw(_spriteBatch, _graphics.PreferredBackBufferWidth);
    
    // Отображаем пропущенные квадраты
    string missedText = $"Пропущено: {_missedSquares}/{_maxMissedSquares}";
    _spriteBatch.DrawString(_font, missedText, new Vector2(10, 40), Color.White);
    
    // Если игра окончена, показываем сообщение
    if (_gameState == GameState.GameOver)
    {
        string gameOverText = "ИГРА ОКОНЧЕНА";
        string restartText = "Нажмите R для рестарта";
        
        Vector2 gameOverSize = _font.MeasureString(gameOverText);
        Vector2 restartSize = _font.MeasureString(restartText);
        
        _spriteBatch.DrawString(_font, gameOverText,
            new Vector2(_graphics.PreferredBackBufferWidth / 2 - gameOverSize.X / 2,
                        _graphics.PreferredBackBufferHeight / 2 - gameOverSize.Y),
            Color.Red, 0, Vector2.Zero, 2f, SpriteEffects.None, 0);
            
        _spriteBatch.DrawString(_font, restartText,
            new Vector2(_graphics.PreferredBackBufferWidth / 2 - restartSize.X / 2,
                        _graphics.PreferredBackBufferHeight / 2 + 50),
            Color.White);
    }
    
    _spriteBatch.End();
    
    base.Draw(gameTime);
}
 
private void RestartGame()
{
    _squares.Clear();
    _missedSquares = 0;
    _scoreManager = new ScoreManager(_font);
    _squareSpawnTimer = 0;
    _squareSpawnDelay = 2.0f;
    _gameState = GameState.Playing;
}

Запуск и тестирование



Вот и всё! Наша игра "Поймай квадрат" готова. При запуске вы увидите платформу внизу экрана, которой можно управлять с помощью клавиш A/D или стрелок влево/вправо. Сверху будут падать разноцветные квадраты, которые нужно ловить. За каждый пойманный квадрат игрок получает очко, а с каждым уровнем скорость падения и частота появления квадратов увеличиваются. Я специально сделал игру максимально простой, чтобы продемонстрировать основные концепции MonoGame. Но даже в такой простой игре мы использовали:
  • Игровой цикл с методами Update и Draw,
  • Обработку пользовательского ввода,
  • Проверку коллизий,
  • Управление игровыми состояниями,
  • Систему подсчета очков,
  • Визуальные эффекты (система частиц),
  • Звуковые эффекты,

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

Листинг приложения



Теперь давайте соберем все части нашей игры "Поймай квадрат" в единый полный листинг. Это позволит вам скопировать код и сразу запустить готовое приложение. Начнем с организации файлов нашего проекта:

C#
1
2
3
4
5
6
7
8
9
10
11
12
CatchTheSquare/
├── Content/
│   ├── Content.mgcb    # Файл контент-менеджера
│   ├── Font.spritefont # Шрифт для текста
│   ├── Catch.wav       # Звук при ловле квадрата
│   └── Miss.wav        # Звук при пропуске квадрата
├── Game1.cs            # Основной класс игры
├── Player.cs           # Класс платформы игрока
├── Square.cs           # Класс падающих квадратов
├── ScoreManager.cs     # Управление очками
├── ParticleSystem.cs   # Система частиц
└── Program.cs          # Точка входа в приложение
Теперь приведу полный код каждого файла. Начнем с точки входа Program.cs:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
using System;
 
namespace CatchTheSquare
{
    public static class Program
    {
        [STAThread]
        static void Main()
        {
            using (var game = new Game1())
                game.Run();
        }
    }
}
Файл Player.cs:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System;
 
namespace CatchTheSquare
{
    public class Player
    {
        public Vector2 Position { get; set; }
        public Texture2D Texture { get; private set; }
        public float Speed { get; set; } = 400f;
        public Rectangle Bounds => new Rectangle(
            (int)Position.X - Texture.Width / 2, 
            (int)Position.Y - Texture.Height / 2, 
            Texture.Width, 
            Texture.Height);
        
        public Player(Texture2D texture, Vector2 position)
        {
            Texture = texture;
            Position = position;
        }
        
        public void Update(GameTime gameTime, int screenWidth)
        {
            float deltaTime = (float)gameTime.ElapsedGameTime.TotalSeconds;
            KeyboardState keyboardState = Keyboard.GetState();
            
            // Перемещение влево/вправо
            if (keyboardState.IsKeyDown(Keys.Left) || keyboardState.IsKeyDown(Keys.A))
                Position.X -= Speed * deltaTime;
            if (keyboardState.IsKeyDown(Keys.Right) || keyboardState.IsKeyDown(Keys.D))
                Position.X += Speed * deltaTime;
            
            // Ограничиваем движение экраном
            Position.X = Math.Clamp(Position.X, Texture.Width / 2, screenWidth - Texture.Width / 2);
        }
        
        public void Draw(SpriteBatch spriteBatch)
        {
            spriteBatch.Draw(
                Texture, 
                Position, 
                null, 
                Color.LightBlue, 
                0f, 
                new Vector2(Texture.Width / 2, Texture.Height / 2), 
                1f, 
                SpriteEffects.None, 
                0f);
        }
    }
}
Файл Square.cs:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
 
namespace CatchTheSquare
{
    public class Square
    {
        public Vector2 Position { get; set; }
        public Vector2 Velocity { get; set; }
        public Color Color { get; set; }
        public Texture2D Texture { get; private set; }
        public bool IsActive { get; set; } = true;
        
        public Rectangle Bounds => new Rectangle(
            (int)Position.X - Texture.Width / 2, 
            (int)Position.Y - Texture.Height / 2, 
            Texture.Width, 
            Texture.Height);
        
        public Square(Texture2D texture, Vector2 position, Vector2 velocity, Color color)
        {
            Texture = texture;
            Position = position;
            Velocity = velocity;
            Color = color;
        }
        
        public void Update(GameTime gameTime)
        {
            float deltaTime = (float)gameTime.ElapsedGameTime.TotalSeconds;
            Position += Velocity * deltaTime;
        }
        
        public void Draw(SpriteBatch spriteBatch)
        {
            if (IsActive)
                spriteBatch.Draw(
                    Texture, 
                    Position, 
                    null, 
                    Color, 
                    0f, 
                    new Vector2(Texture.Width / 2, Texture.Height / 2), 
                    1f, 
                    SpriteEffects.None, 
                    0f);
        }
    }
}
Файл ScoreManager.cs:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using System;
 
namespace CatchTheSquare
{
    public class ScoreManager
    {
        public int Score { get; private set; }
        public int Level => Math.Max(1, Score / 10 + 1); // Каждые 10 очков - новый уровень
        private SpriteFont _font;
        
        public ScoreManager(SpriteFont font)
        {
            _font = font;
            Score = 0;
        }
        
        public void AddScore(int points)
        {
            Score += points;
        }
        
        public void Draw(SpriteBatch spriteBatch, int screenWidth)
        {
            string scoreText = $"Очки: {Score}";
            string levelText = $"Уровень: {Level}";
            
            spriteBatch.DrawString(_font, scoreText, new Vector2(10, 10), Color.White);
            spriteBatch.DrawString(
                _font, 
                levelText, 
                new Vector2(screenWidth - _font.MeasureString(levelText).X - 10, 10), 
                Color.White);
        }
    }
}
Файл ParticleSystem.cs:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using System;
using System.Collections.Generic;
 
namespace CatchTheSquare
{
    public class ParticleSystem
    {
        private struct Particle
        {
            public Vector2 Position;
            public Vector2 Velocity;
            public Color Color;
            public float Size;
            public float Lifetime;
            public float Age;
        }
        
        private List<Particle> _particles = new List<Particle>();
        private Texture2D _particleTexture;
        private Random _random = new Random();
        
        public ParticleSystem(GraphicsDevice graphicsDevice)
        {
            // Создаем одно-пиксельную текстуру для частиц
            _particleTexture = new Texture2D(graphicsDevice, 1, 1);
            _particleTexture.SetData(new[] { Color.White });
        }
        
        public void AddParticles(Vector2 position, Color color, int count)
        {
            for (int i = 0; i < count; i++)
            {
                _particles.Add(new Particle
                {
                    Position = position,
                    Velocity = new Vector2(
                        (float)(_random.NextDouble() * 2 - 1) * 100,
                        (float)(_random.NextDouble() * 2 - 1) * 100
                    ),
                    Color = color,
                    Size = (float)_random.NextDouble() * 5 + 1,
                    Lifetime = (float)_random.NextDouble() * 0.5f + 0.5f,
                    Age = 0
                });
            }
        }
        
        public void Update(GameTime gameTime)
        {
            float deltaTime = (float)gameTime.ElapsedGameTime.TotalSeconds;
            
            for (int i = _particles.Count - 1; i >= 0; i--)
            {
                Particle particle = _particles[i];
                particle.Age += deltaTime;
                
                if (particle.Age >= particle.Lifetime)
                {
                    _particles.RemoveAt(i);
                    continue;
                }
                
                particle.Position += particle.Velocity * deltaTime;
                particle.Velocity *= 0.97f; // Небольшое замедление
                _particles[i] = particle;
            }
        }
        
        public void Draw(SpriteBatch spriteBatch)
        {
            foreach (var particle in _particles)
            {
                float alpha = 1f - (particle.Age / particle.Lifetime);
                Color color = particle.Color * alpha;
                
                spriteBatch.Draw(
                    _particleTexture,
                    particle.Position,
                    null,
                    color,
                    0f,
                    Vector2.Zero,
                    particle.Size,
                    SpriteEffects.None,
                    0f
                );
            }
        }
    }
}
И наконец, основной файл Game1.cs:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System;
using System.Collections.Generic;
 
namespace CatchTheSquare
{
    public class Game1 : Game
    {
        private GraphicsDeviceManager _graphics;
        private SpriteBatch _spriteBatch;
        
        private Player _player;
        private List<Square> _squares;
        private ScoreManager _scoreManager;
        private ParticleSystem _particleSystem;
        
        private Texture2D _squareTexture;
        private Texture2D _playerTexture;
        private SpriteFont _font;
        private SoundEffect _catchSound;
        private SoundEffect _missSound;
        
        private Random _random;
        private float _squareSpawnTimer;
        private float _squareSpawnDelay = 2.0f; // Начальная задержка 2 секунды
        private int _missedSquares;
        private int _maxMissedSquares = 5;
        private GameState _gameState = GameState.Playing;
        
        public enum GameState
        {
            Playing,
            GameOver
        }
        
        public Game1()
        {
            _graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";
            IsMouseVisible = true;
            
            _graphics.PreferredBackBufferWidth = 800;
            _graphics.PreferredBackBufferHeight = 600;
            _graphics.ApplyChanges();
        }
        
        protected override void Initialize()
        {
            base.Initialize();
        }
        
        protected override void LoadContent()
        {
            _spriteBatch = new SpriteBatch(GraphicsDevice);
            
            // Создаем текстуру квадрата 32x32 пикселя
            _squareTexture = new Texture2D(GraphicsDevice, 32, 32);
            Color[] data = new Color[32 * 32];
            for (int i = 0; i < data.Length; i++)
                data[i] = Color.White; // Белый цвет, который мы будем тонировать при отрисовке
            _squareTexture.SetData(data);
            
            // Создаем текстуру платформы 128x16 пикселей
            _playerTexture = new Texture2D(GraphicsDevice, 128, 16);
            data = new Color[128 * 16];
            for (int i = 0; i < data.Length; i++)
                data[i] = Color.White;
            _playerTexture.SetData(data);
            
            // Загружаем шрифт
            _font = Content.Load<SpriteFont>("Font");
            
            // Загружаем звуки
            _catchSound = Content.Load<SoundEffect>("Catch");
            _missSound = Content.Load<SoundEffect>("Miss");
            
            // Инициализируем игровые объекты
            _random = new Random();
            _squares = new List<Square>();
            _player = new Player(_playerTexture, new Vector2(_graphics.PreferredBackBufferWidth / 2, _graphics.PreferredBackBufferHeight - 30));
            _scoreManager = new ScoreManager(_font);
            _particleSystem = new ParticleSystem(GraphicsDevice);
        }
        
        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || 
                Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();
            
            float deltaTime = (float)gameTime.ElapsedGameTime.TotalSeconds;
            
            if (_gameState == GameState.Playing)
            {
                // Обновляем игрока
                _player.Update(gameTime, _graphics.PreferredBackBufferWidth);
                
                // Обновляем существующие квадраты
                for (int i = _squares.Count - 1; i >= 0; i--)
                {
                    _squares[i].Update(gameTime);
                    
                    // Проверка коллизий с игроком
                    if (_squares[i].Bounds.Intersects(_player.Bounds) && _squares[i].IsActive)
                    {
                        _squares[i].IsActive = false;
                        _scoreManager.AddScore(1);
                        _catchSound.Play(0.5f, 0, 0);
                        _particleSystem.AddParticles(_squares[i].Position, _squares[i].Color, 20);
                        _squares.RemoveAt(i);
                        continue;
                    }
                    
                    // Проверка, не упал ли квадрат за пределы экрана
                    if (_squares[i].Position.Y > _graphics.PreferredBackBufferHeight + 20)
                    {
                        _squares.RemoveAt(i);
                        _missedSquares++;
                        _missSound.Play(0.5f, 0, 0);
                        
                        if (_missedSquares >= _maxMissedSquares)
                            _gameState = GameState.GameOver;
                    }
                }
                
                // Генерация новых квадратов
                _squareSpawnTimer += deltaTime;
                if (_squareSpawnTimer >= _squareSpawnDelay)
                {
                    _squareSpawnTimer = 0;
                    
                    // С каждым уровнем квадраты падают быстрее и чаще
                    _squareSpawnDelay = Math.Max(0.5f, 2.0f - (_scoreManager.Level - 1) * 0.1f);
                    
                    // Создаем квадрат в случайной позиции с случайным цветом
                    float x = (float)_random.NextDouble() * (_graphics.PreferredBackBufferWidth - 50) + 25;
                    Vector2 position = new Vector2(x, -20);
                    Vector2 velocity = new Vector2(0, 100 + _scoreManager.Level * 20);
                    
                    Color color = new Color(
                        (float)_random.NextDouble(),
                        (float)_random.NextDouble(),
                        (float)_random.NextDouble()
                    );
                    
                    _squares.Add(new Square(_squareTexture, position, velocity, color));
                }
            }
            else if (_gameState == GameState.GameOver)
            {
                // Проверка нажатия для рестарта
                if (Keyboard.GetState().IsKeyDown(Keys.R))
                    RestartGame();
            }
            
            // Обновляем систему частиц в любом случае
            _particleSystem.Update(gameTime);
            
            base.Update(gameTime);
        }
        
        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.Black);
            
            _spriteBatch.Begin();
            
            // Рисуем игровые объекты
            foreach (var square in _squares)
                square.Draw(_spriteBatch);
            
            _player.Draw(_spriteBatch);
            _particleSystem.Draw(_spriteBatch);
            
            // Рисуем интерфейс
            _scoreManager.Draw(_spriteBatch, _graphics.PreferredBackBufferWidth);
            
            // Отображаем пропущенные квадраты
            string missedText = $"Пропущено: {_missedSquares}/{_maxMissedSquares}";
            _spriteBatch.DrawString(_font, missedText, new Vector2(10, 40), Color.White);
            
            // Если игра окончена, показываем сообщение
            if (_gameState == GameState.GameOver)
            {
                string gameOverText = "ИГРА ОКОНЧЕНА";
                string restartText = "Нажмите R для рестарта";
                
                Vector2 gameOverSize = _font.MeasureString(gameOverText);
                Vector2 restartSize = _font.MeasureString(restartText);
                
                _spriteBatch.DrawString(_font, gameOverText,
                    new Vector2(_graphics.PreferredBackBufferWidth / 2 - gameOverSize.X / 2,
                                _graphics.PreferredBackBufferHeight / 2 - gameOverSize.Y),
                    Color.Red, 0, Vector2.Zero, 2f, SpriteEffects.None, 0);
                    
                _spriteBatch.DrawString(_font, restartText,
                    new Vector2(_graphics.PreferredBackBufferWidth / 2 - restartSize.X / 2,
                                _graphics.PreferredBackBufferHeight / 2 + 50),
                    Color.White);
            }
            
            _spriteBatch.End();
            
            base.Draw(gameTime);
        }
        
        private void RestartGame()
        {
            _squares.Clear();
            _missedSquares = 0;
            _scoreManager = new ScoreManager(_font);
            _squareSpawnTimer = 0;
            _squareSpawnDelay = 2.0f;
            _gameState = GameState.Playing;
        }
    }
}
Для работы проекта вам также понадобится создать файл шрифта Font.spritefont в папке Content. Это XML-файл, который можно сгенерировать через редактор MGCB, либо создать вручную. Также необходимо добавить звуковые эффекты Catch.wav и Miss.wav через тот же редактор.

Разбор основных паттернов и решений



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

Архитектура с разделением ответственности



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

Game1 — основной класс, координирующий работу всех компонентов;
Player — логика платформы игрока;
Square — поведение падающих квадратов;
ScoreManager — подсчет и отображение очков;
ParticleSystem — визуальные эффекты.

Такая организация соответствует принципу единственной ответственности (Single Responsibility Principle) из SOLID. Каждый класс делает что-то одно, но делает это хорошо. Конечно, в реальном проекте я бы пошёл дальше и вынес бы обработку столкновений, генерацию квадратов и другие функции в отдельные менеджеры.

Компонентный подход



Обратите внимание, что игровые объекты не наследуются от какого-то базового класса GameObject. Вместо этого мы используем композицию — каждый объект содержит необходимые ему компоненты (текстуру, позицию, скорость и т.д.) и реализует методы Update и Draw. Это упрощенная версия паттерна Component Entity System (CES), который широко используется в игровой индустрии. В полноценной реализации объекты были бы просто контейнерами для компонентов, а вся логика выделена в системы, обрабатывающие эти компоненты.

Игровые состояния



В игре реализован паттерн State (Состояние) через перечисление GameState и условные операторы в методах Update и Draw. Это позволяет изменять поведение игры в зависимости от ее текущего состояния (игра, пауза, конец игры). В более сложных проектах я бы рекомендовал использовать полноценную реализацию паттерна State с отдельными классами для каждого состояния. Это особенно полезно, когда логика состояний усложняется.

Пул объектов



Хотя в нашем примере это не реализовано явно, система частиц демонстрирует подход, близкий к паттерну Object Pool (Пул объектов). Мы создаем частицы, используем их, а потом удаляем. В более оптимизированной версии мы бы переиспользовали объекты частиц вместо их постоянного создания и удаления. Этот паттерн особенно важен для мобильных платформ, где частое выделение памяти может вызывать подвисания из-за сборки мусора.

Паттерн Observer (наблюдатель) неявно



Хотя мы не использовали события или интерфейсы наблюдателя напрямую, логика начисления очков и проверки конца игры фактически реализует паттерн Observer. Игра наблюдает за состоянием игрока и квадратов, и реагирует на изменения (поимка квадрата, пропуск квадрата). В более сложной игре имеет смысл явно использовать систему событий для уменьшения связности между компонентами.

Использование значимых типов для оптимизации



Обратите внимание, что структура Particle определена как struct, а не class. Это сделано специально для оптимизации производительности, так как частиц может быть много, и выделение памяти в куче для каждой частицы создало бы ненужную нагрузку на сборщик мусора.

Что можно улучшить



Если бы мы разрабатывали более сложную игру, стоило бы рассмотреть:

1. Использование паттерна Фабрика для создания игровых объектов
2. Внедрение полноценной компонентной системы
3. Реализацию менеджера ресурсов для централизованной загрузки/выгрузки ассетов
4. Применение паттерна Command для обработки ввода
5. Систему сервисов с инъекцией зависимостей

Например, для создания квадратов я бы реализовал фабрику:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class SquareFactory
{
    private Texture2D _squareTexture;
    private Random _random;
    private int _screenWidth;
    
    public SquareFactory(Texture2D texture, int screenWidth)
    {
        _squareTexture = texture;
        _random = new Random();
        _screenWidth = screenWidth;
    }
    
    public Square CreateRandomSquare(int level)
    {
        float x = (float)_random.NextDouble() * (_screenWidth - 50) + 25;
        Vector2 position = new Vector2(x, -20);
        Vector2 velocity = new Vector2(0, 100 + level * 20);
        
        Color color = new Color(
            (float)_random.NextDouble(),
            (float)_random.NextDouble(),
            (float)_random.NextDouble()
        );
        
        return new Square(_squareTexture, position, velocity, color);
    }
}
Такой подход избавит основной класс от деталей создания объектов и сделает код более модульным.
Я считаю, что правильная архитектура — это баланс между гибкостью и простотой. В маленьких проектах слишком сложная архитектура может быть излишней, а в больших — недостаточно продуманная архитектура приведет к трудностям с расширением и поддержкой. Важно выбирать паттерны и решения исходя из конкретных потребностей проекта, а не просто потому, что они модные или популярные.

Не получается запустить Monogame 3.01 под MS Visual Studio 10 и 12
/* Возможно, я пропустил в поиске нужную тему. Киньте мне на неё ссыль, не карайте банами и всем...

Установка XNA или MonoGame
Можно ли в Visual Studio Express 2013 установить XNA Game Studio или MonoGame? Если да, то...

XNA vs. Monogame
Есть игра. Для Windows 8 и Windows Phone. Почему текстуры игры для Windows Phone загружаются в...

Загрузка текстур в MonoGame
Здравствуйте! Начал читать статью по...

SoundEffect в Monogame: исключение типа SharpDX.SharpDXException
ДОбрый день) У меня странно проигрывается звуки в моногайм. Все загружается. Если проигрывать...

MonoGame странно себя ведет, не создаются проекты
Решил посмотреть движок, установил с офф. сайта MonoGameInstaller-3.2.exe (галочки не снимал)...

XNA(Monogame) 2D камера
Всем доброго времени суток! Такой вопрос, как отдалить и приблизить камеру в 2D пространстве? Есть...

Unity3D или MonoGame(XNA)?
Позвольте в вкратце рассказать о себе. Я инженер проектировщик (не программист) и всегда...

Функция движения объектов. MonoGame, C#
Пишу игру в стиле Jeweled. Возникла следующая проблема - при &quot;схлопывании&quot; шариков визуализация...

Загрузка контента в Monogame
Начал изучать monogame и столкнулся с такой проблемкой, создал контент проект XNA 4.0 для...

Не работают проекты MonoGame в VS 2015
Всем здравствуйте. Скачал последнюю версию monogame 3.4 на VS 2015. Выбираю создать проект -...

Состоялся релиз Monogame 3.5
Всем привет! 17 марта состоялся релиз Monogame 3.5. Напомню, что предыдущая версия, 3.4, была...

Размещено в Без категории
Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
[golang] Breadth-First Search
alhaos 19.05.2026
BFS (Breadth-First Search) — это базовый алгоритм обхода графа в ширину, который поуровнево исследует все связанные вершины. Он начинает с выбранной точки и проверяет всех соседей, прежде чем. . .
[golang] Алгоритм «Хак Госпера»
alhaos 17.05.2026
Алгоритм «Хак Госпера» Хак Госпера (Gosper's Hack) — алгоритм нахождения следующего по величине числа с тем же количеством установленных бит. Придуман Биллом Госпером в 1970-х, опубликован в. . .
Рисование бинарного древа до 6-го колена на js, svg.
russiannick 17.05.2026
<svg width="335" height="240" viewBox="0 0 335 240" fill="#e5e1bb"> <style> <!]> </ style> <g id="bush"> </ g> </ svg> function fn(){ let rost;/ / высота древа let xx=165,yy=210,w=256;
FSharp: interface of module
DevAlt 16.05.2026
Интерфейс модуля F# позволяет управлять доступностью членов, содержащихся в реализации модуля. По-умолчанию все члены модуля доступны: module Foo let x = 10 let boo () = printfn "boo" . . .
Хитросплетение родственных связей пантеона греческих богов.
russiannick 14.05.2026
Однооконник, позволяющий узреть и изучить отдельных героев древней Греции. <!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible". . .
[golang] Угол между стрелками часов
alhaos 12.05.2026
По заданным значениям часа и минуты необходимо определить значение меньшего угла между стрелками аналогового циферблата часов. import "math" func angleClock(hour int, minutes int) float64 { . . .
Debian 13: Установка Lazarus QT5
ВитГо 09.05.2026
Эта инструкция моя компиляция инструкций volvo https:/ / www. cyberforum. ru/ blogs/ 203668/ 10753. html и его же старой инструкции по установке Lazarus с gtk2. . .
Нейросеть на алгоритме "эстафета хвоста" как перспектива.
Hrethgir 06.05.2026
На десерт, когда запущу сервер. Статья тут https:/ / habr. com/ ru/ articles/ 1030914/ . Автор я сам, нейросеть только помогает в вопросах которые мне не известны - не знаю людей которые знали-бы. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2026, CyberForum.ru