diff --git a/articles/getting_started/5_adding_basic_code.md b/articles/getting_started/5_adding_basic_code.md index 918385ba..b6fc27ed 100644 --- a/articles/getting_started/5_adding_basic_code.md +++ b/articles/getting_started/5_adding_basic_code.md @@ -319,6 +319,6 @@ We recommend browsing through the [Getting to know MonoGame](../getting_to_know/ ## Further Reading -Check out the [Tutorials section](../tutorials.md) for many more helpful guides and tutorials on building games with MonoGame. We have an expansive library of helpful content, all provided by other MonoGame developers in the community. +Check out the [Tutorials section](../tutorials/index.md) for many more helpful guides and tutorials on building games with MonoGame. We have an expansive library of helpful content, all provided by other MonoGame developers in the community. Additionally, be sure to check out the official [MonoGame Samples](../samples.md) page for fully built sample projects built with MonoGame and targeting our most common platforms. diff --git a/articles/help_and_support.md b/articles/help_and_support.md index b5a41498..d6d6ea25 100644 --- a/articles/help_and_support.md +++ b/articles/help_and_support.md @@ -5,7 +5,7 @@ description: Where to get help and support when using MonoGame. # Help and Support -There is a wealth of [community created content, blogs and tutorials](tutorials.md) available. +There is a wealth of [community created content, blogs and tutorials](tutorials/index.md) available. If you want to find an answer to a more specific problem, you can ask it on our [GitHub Discussions](https://github.com/MonoGame/MonoGame/discussions) page. diff --git a/articles/toc.yml b/articles/toc.yml index aa673967..3e1841d1 100644 --- a/articles/toc.yml +++ b/articles/toc.yml @@ -67,48 +67,106 @@ items: - name: Getting to know MonoGame href: getting_to_know/ items: - - name: What is - href: getting_to_know/whatis/ - items: - - name: Audio - href: getting_to_know/whatis/audio/ - - name: Content Pipeline - href: getting_to_know/whatis/content_pipeline/ - - name: Graphics - href: getting_to_know/whatis/graphics/ - - name: Input - href: getting_to_know/whatis/input/ - - name: The Game Loop - href: getting_to_know/whatis/game_loop/ - - name: Vector / Matrix / Quaternions - href: getting_to_know/whatis/vector_matrix_quat/ - - name: MonoGame Class Library - href: getting_to_know/whatis/monogame_class_library/ - - name: How to - href: getting_to_know/howto/ - items: - - name: Audio - href: getting_to_know/howto/audio/ - - name: Content Pipeline - href: getting_to_know/howto/content_pipeline/ - - name: Graphics - href: getting_to_know/howto/graphics/ - - name: Input - href: getting_to_know/howto/input/ + - name: What is + href: getting_to_know/whatis/ + items: + - name: Audio + href: getting_to_know/whatis/audio/ + - name: Content Pipeline + href: getting_to_know/whatis/content_pipeline/ + - name: Graphics + href: getting_to_know/whatis/graphics/ + - name: Input + href: getting_to_know/whatis/input/ + - name: The Game Loop + href: getting_to_know/whatis/game_loop/ + - name: Vector / Matrix / Quaternions + href: getting_to_know/whatis/vector_matrix_quat/ + - name: MonoGame Class Library + href: getting_to_know/whatis/monogame_class_library/ + - name: How to + href: getting_to_know/howto/ + items: + - name: Audio + href: getting_to_know/howto/audio/ + - name: Content Pipeline + href: getting_to_know/howto/content_pipeline/ + - name: Graphics + href: getting_to_know/howto/graphics/ + - name: Input + href: getting_to_know/howto/input/ - name: Migration items: - - name: Migrating from XNA - href: migration/migrate_xna.md - - name: Migrating from 3.7 - href: migration/migrate_37.md - - name: Migrating from 3.8.0 - href: migration/migrate_38.md - - name: Updating Versions - href: migration/updating_versions.md + - name: Migrating from XNA + href: migration/migrate_xna.md + - name: Migrating from 3.7 + href: migration/migrate_37.md + - name: Migrating from 3.8.0 + href: migration/migrate_38.md + - name: Updating Versions + href: migration/updating_versions.md - name: Samples and Demos href: samples.md -- name: Community Tutorials - href: tutorials.md +- name: Tutorials + href: tutorials/ + items: + - name: Building 2D Games + href: tutorials/building_2d_games/ + items: + - name: "01: What Is MonoGame?" + href: tutorials/building_2d_games/01_what_is_monogame/ + - name: "02: Getting Started" + href: tutorials/building_2d_games/02_getting_started/ + - name: "03: The Game1 File" + href: tutorials/building_2d_games/03_the_game1_file/ + - name: "04: Creating a Class Library" + href: tutorials/building_2d_games/04_creating_a_class_library/ + - name: "05: Content Pipeline" + href: tutorials/building_2d_games/05_content_pipeline/ + - name: "06: Working with Textures" + href: tutorials/building_2d_games/06_working_with_textures/ + - name: "07: Optimizing Texture Rendering" + href: tutorials/building_2d_games/07_optimizing_texture_rendering/ + - name: "08: The Sprite Class" + href: tutorials/building_2d_games/08_the_sprite_class/ + - name: "09: The AnimatedSprite Class" + href: tutorials/building_2d_games/09_the_animatedsprite_class/ + - name: "10: Handling Input" + href: tutorials/building_2d_games/10_handling_input/ + - name: "11: Input Management" + href: tutorials/building_2d_games/11_input_management/ + - name: "12: Collision Detection" + href: tutorials/building_2d_games/12_collision_detection/ + - name: "13: Working With Tilemaps" + href: tutorials/building_2d_games/13_working_with_tilemaps/ + - name: "14: Sound Effects and Music" + href: tutorials/building_2d_games/14_soundeffects_and_music/ + - name: "15: Audio Controller" + href: tutorials/building_2d_games/15_audio_controller/ + - name: "16: Working with SpriteFonts" + href: tutorials/building_2d_games/16_working_with_spritefonts/ + - name: "17: Scenes" + href: tutorials/building_2d_games/17_scenes/ + - name: "18: Texture Sampling" + href: tutorials/building_2d_games/18_texture_sampling/ + - name: "19: User Interface Fundamentals" + href: tutorials/building_2d_games/19_user_interface_fundamentals/ + - name: "20: Implementing UI with Gum" + href: tutorials/building_2d_games/20_implementing_ui_with_gum/ + - name: "21: Customizing Gum UI" + href: tutorials/building_2d_games/21_customizing_gum_ui/ + - name: "22: Snake Game Mechanics" + href: tutorials/building_2d_games/22_snake_game_mechanics/ + - name: "23: Completing the Game" + href: tutorials/building_2d_games/23_completing_the_game/ + - name: "24: Shaders" + href: tutorials/building_2d_games/24_shaders/ + - name: "25: Packaging Your Game for Distribution" + href: tutorials/building_2d_games/25_packaging_game/ + - name: "26: Publishing Your Game to itch.io" + href: tutorials/building_2d_games/26_publish_to_itch/ + - name: "27: Conclusion and Next Steps" + href: tutorials/building_2d_games/27_conclusion/ - name: Console Access href: console_access.md - name: Help and Support diff --git a/articles/tutorials/building_2d_games/01_what_is_monogame/images/bastion.jpg b/articles/tutorials/building_2d_games/01_what_is_monogame/images/bastion.jpg new file mode 100644 index 00000000..316df14c Binary files /dev/null and b/articles/tutorials/building_2d_games/01_what_is_monogame/images/bastion.jpg differ diff --git a/articles/tutorials/building_2d_games/01_what_is_monogame/images/celeste.png b/articles/tutorials/building_2d_games/01_what_is_monogame/images/celeste.png new file mode 100644 index 00000000..c9bd51d8 Binary files /dev/null and b/articles/tutorials/building_2d_games/01_what_is_monogame/images/celeste.png differ diff --git a/articles/tutorials/building_2d_games/01_what_is_monogame/images/sor4.jpg b/articles/tutorials/building_2d_games/01_what_is_monogame/images/sor4.jpg new file mode 100644 index 00000000..5f6a4710 Binary files /dev/null and b/articles/tutorials/building_2d_games/01_what_is_monogame/images/sor4.jpg differ diff --git a/articles/tutorials/building_2d_games/01_what_is_monogame/images/stardew-valley.png b/articles/tutorials/building_2d_games/01_what_is_monogame/images/stardew-valley.png new file mode 100644 index 00000000..e8be681e Binary files /dev/null and b/articles/tutorials/building_2d_games/01_what_is_monogame/images/stardew-valley.png differ diff --git a/articles/tutorials/building_2d_games/01_what_is_monogame/index.md b/articles/tutorials/building_2d_games/01_what_is_monogame/index.md new file mode 100644 index 00000000..13bc416a --- /dev/null +++ b/articles/tutorials/building_2d_games/01_what_is_monogame/index.md @@ -0,0 +1,87 @@ +--- +title: "Chapter 01: What is MonoGame" +description: Learn about the history of MonoGame and explore the features it provides to developers when creating games. +--- + +## A Brief History + +In 2006, Microsoft released a game development framework named *[XNA Game Studio](https://learn.microsoft.com/en-us/previous-versions/windows/xna/bb203894(v=xnagamestudio.42))* to facilitate game development for Windows PC and the Xbox 360 console. It revolutionized game development for indie creators by bringing a simplified approach to building games and offering a set of tools that lowered the entry barrier for aspiring game developers. Out of XNA Game Studio came critically acclaimed titles such as [Bastion](https://www.supergiantgames.com/games/bastion/) and [Terraria](https://terraria.org/). In 2008, XNA was expanded to support development for both the Zune and Windows Phone. + +> [!NOTE] +> +> Fun fact, provided by community member [stromkos](https://github.com/stromkos), The release of XNA 3.0 in 2008, which added the support for Windows Phone, is also the release that specified the default window resolution of 800x480 for new projects as this was the preferred resolution on Windows Phone. [It is still the default resolution used in MonoGame projects today](https://github.com/MonoGame/MonoGame/blob/8b35cf50783777507cd6b21828ed0109b3b07b50/MonoGame.Framework/GraphicsDeviceManager.cs#L44). + +As XNA became more popular, the need for cross-platform development started to grow. In 2009, [José Antonio Leal de Farias](https://github.com/jalf) introduced *XNA Touch*, an open-source project that aimed to make games with XNA playable on iOS devices. This marked the beginning of what would later become MonoGame. [Dominique Louis](https://github.com/CartBlanche) came on board in 2009 and soon took over as full-time project lead, driving its initial development and expansion. The project attracted other developers, such as [Tom Spilman](https://github.com/tomspilman), who were interested in expanding the scope of the project as well as its reach. + +The official first release of MonoGame occurred in 2011, as an open-source version of XNA. While it still had the same familiar API as XNA, the cross-platform support was expanded to include Windows, macOS, Linux, iOS, Android, Xbox, and PlayStation. Despite Microsoft discontinuing XNA in 2013, MonoGame continued to grow and develop. Maintenance of the project was given to [Steve Williams](https://github.com/KonajuGames) and [Tom Spilman](https://github.com/tomspilman) in 2014. In order to direct its future development and undertaking, the [MonoGame Foundation](https://monogame.net/about/) was formed on September 29th, 2023. + +Today, MonoGame is a mature cross-platform framework, that is built with the spirit of preserving XNA while adopting modern game development practices. Some popular titles created using MonoGame includes [Celeste](https://store.steampowered.com/app/504230/Celeste/), [Stardew Valley](https://store.steampowered.com/app/413150/Stardew\_Valley/), and [Streets of Rage 4](https://store.steampowered.com/app/985890/Streets\_of\_Rage\_4/). + +| ![Figure 1-1: Celeste](./images/celeste.png) | ![Figure 1-2: Stardew Valley](./images/stardew-valley.png) | +| :-------------------------------------------------: | :--------------------------------------------------------: | +| **Figure 1-1 Celeste.** | **Figure 1-2: Stardew Valley** | +| ![Figure 1-3: Streets of Rage 4](./images/sor4.jpg) | [Figure 1-4: Bastion](./images/bastion.jpg) | +| **Figure 1-3: Streets of Rage 4** | **Figure 1-4: Bastion** | + +> [!NOTE] +> +> For more details about MonoGame's history, check the [About](https://monogame.net/about/) on the official MonoGame website. + +## Features + +MonoGame, following in the footsteps of XNA, is a "bring your own tools" framework. It provides developers the basic blocks to design the game, engines, and/or tools. As a code-first approach to game development, MonoGame does not include any pre-built editors or interfaces; instead, it gives developers the freedom to create their own working environment. + +### API + +At its core, MonoGame offers a set of libraries and APIs to handle common game development tasks. These include: + +1. **Graphics Rendering**: 2D and 3D rendering are supported through the graphics API offered by MonoGame. This API provides sprite batching for 2D graphics, a flexible 3D pipeline, and shaders for custom visuals and effects. +2. **Input Handling**: Input from keyboard, mouse, gamepads, and touchscreens are supported, allowing for development of games for any platform and different styles of play. +3. **Audio**: A comprehensive audio system that can be used to create sound effects as well as play music with included support for many audio formats. +4. **Content Pipeline**: An out-of-the-box workflow for importing and processing game assets such as textures, models, and audio, and compiling them to a format that is optimal for the game's target platform. +5. **Math Library**: A math library specifically optimized for game development, providing essential mathematical functions and operations. + +### Cross Platform + +One of the main advantages of MonoGame is its cross-platform support. Games built with MonoGame are compatible with a variety of platforms, including: + +* **Desktop**: Windows, macOS, and Linux. +* **Mobile**: iOS and Android. +* **Consoles** [(with appropriate license)](https://docs.monogame.net/articles/console\_access.html): Xbox, PlayStation, and Nintendo Switch. + +By providing cross-platform support, developers can target multiple platforms from a single code base, significantly reducing development time and resources needed for porting. + +### Programming Language Support + +MonoGame is designed and built in C#. It is the official programming language supported in documentation, samples, and community discussion. However, MonoGame is not exclusively tied to C#. As a .NET library, MonoGame can be used with any .NET-compatible language, including Visual Basic and F#. + +> [!CAUTION] +> While the alternative .NET languages can be used, community support may be limited outside the scope of C#. + +Regardless of which .NET language is used, developers should have a foundational understanding of the language and programming concepts such as: + +* Object-oriented programming. +* Data types and structures. +* Control flow and loops. +* Error handling and debugging. + +## See Also + +* [About MonoGame | MonoGame](https://monogame.net/about) + +## Test Your Knowledge + +1. Name one of the advantages of using the MonoGame framework to develop games. + + :::question-answer + Any of the following are advantages of using the MonoGame framework. + 1. It provides cross-platform support, allowing developers to target multiple platforms from a single code base. + 2. It offers a set of libraries and APIs common for game development tasks, such as graphics rendering, input handling, audio, and content management. + 3. It is a "bring your own tools" framework, giving developers flexibility in their working environment. + ::: + +2. What programming languages can be used when creating a game with MonoGame? + + :::question-answer + The primary language used is C#, which is the same language that the MonoGame framework is developed in. However, any .NET language can be used, such as F# or Visual Basic. + ::: diff --git a/articles/tutorials/building_2d_games/02_getting_started/images/devkit-extension.png b/articles/tutorials/building_2d_games/02_getting_started/images/devkit-extension.png new file mode 100644 index 00000000..586df141 Binary files /dev/null and b/articles/tutorials/building_2d_games/02_getting_started/images/devkit-extension.png differ diff --git a/articles/tutorials/building_2d_games/02_getting_started/images/game-window.png b/articles/tutorials/building_2d_games/02_getting_started/images/game-window.png new file mode 100644 index 00000000..1aef5ef4 Binary files /dev/null and b/articles/tutorials/building_2d_games/02_getting_started/images/game-window.png differ diff --git a/articles/tutorials/building_2d_games/02_getting_started/images/vscode.png b/articles/tutorials/building_2d_games/02_getting_started/images/vscode.png new file mode 100644 index 00000000..bc0b67c7 Binary files /dev/null and b/articles/tutorials/building_2d_games/02_getting_started/images/vscode.png differ diff --git a/articles/tutorials/building_2d_games/02_getting_started/index.md b/articles/tutorials/building_2d_games/02_getting_started/index.md new file mode 100644 index 00000000..5b04c73f --- /dev/null +++ b/articles/tutorials/building_2d_games/02_getting_started/index.md @@ -0,0 +1,264 @@ +--- +title: "Chapter 02: Getting Started" +description: Setup your development environment for .NET development and MonoGame using Visual Studio Code as your IDE. +--- + +Unlike game engines (such as Unity, Unreal or Godot), MonoGame is a *framework*. This means it does not come as a standalone program that you download and install, and does not include a graphical user interface used to create games. Instead, MonoGame integrates into the standard .NET development workflow, offering a code-first approach to game development. This approach offers several advantages: + +* **Flexibility**: Developers are not locked into using a specific editor or interface, allowing them to use their preferred development tools. +* **Integration**: As a .NET library itself, MonoGame can easily integrate with other .NET libraries and tools. +* **Cross-platform Development**: Since C# is cross-platform, and MonoGame is cross-platform, developers can develop MonoGame projects on Windows, macOS, or Linux, with only slight differences in the setup process for each operating system. +* **Version Control Friendly**: The code-first approach makes it easier to use version control systems like Git for your game projects. + +While the environment setup process is similar to the standard setup process for C# development, there are some MonoGame specific steps. These can vary slightly depending on your operating system and the *Integrated Development Environment* (IDE). + +## Installing the .NET SDK + +The first thing we need to do is install the .NET *Software Development Kit* (SDK). To install it, follow the instructions based on your operating system below: + +> [!IMPORTANT] +> As of MonoGame 3.8.2, the minimum supported version of the .NET SDK is .NET 8. + +### [Windows](#tab/windows) + +1. Open a web browser and navigate to [https://dotnet.microsoft.com/en-us/download](https://dotnet.microsoft.com/en-us/download). +2. Choose the version of the .NET SDK to install and click the **Download .NET SDK x64** button to start the download. + + > [!NOTE] + > The minimum supported version is .NET 8 + +3. Once the download finishes, run the installer + +### [macOS](#tab/macos) + +1. Open a web browser and navigate to [https://dotnet.microsoft.com/en-us/download](https://dotnet.microsoft.com/en-us/download). +2. Choose the version of the .NET SDK to install and click lick the *Download .NET SDK x64 (Intel)* button start the download of the .NET SDK Installer. + + > [!NOTE] + > The minimum supported version is .NET 8 + +3. Once the download finishes, run the installer. + +> [!NOTE] +> For the time being, MonoGame requires that you install the **Intel** version even if you are using an Apple Silicon (M1/M2) Mac. For Apple Silicon Macs, it also requires that [Rosetta](https://support.apple.com/en-us/HT211861) be enabled. + +### [Linux](#tab/linux) + +1. Open a new *Terminal* window +2. Enter the following command to install the .NET SDK + +```sh +sudo apt-get update && sudo apt-get install -y dotnet-sdk-8.0 +``` + +> [!NOTE] +> The minimum supported version is .NET 8 + +--- + +## Install Additional Workloads (Optional) + +After installing the .NET SDK, if you intend to target mobile devices such as Android or iOS, you will also need to install the corresponding mobile workloads. To do this, open a *Command Prompt* or *Terminal* window and enter the following commands + +```sh +dotnet workload install ios +dotnet workload install android +``` + +## Install MonoGame Project Templates + +MonoGame provides project templates that can be installed to create new projects that are pre-configured to target the current version of MonoGame as a base to begin creating games. To install the MonoGame templates, open a *Command Prompt* or *Terminal* window and enter the following command + +```sh +dotnet new install MonoGame.Templates.CSharp +``` + +## Installing Visual Studio Code + +*Visual Studio Code* (VSCode) is a free, light weight editor. Depending on the programming language you are using, it is just a matter of installing the appropriate extension to support that particular language. VSCode is also cross-platform, meaning you can use it for development on Windows, macOS, and Linux. To ensure that all readers can follow this tutorial regardless of the operating system used, we will be using VSCode as our IDE. + +To install VSCode, follow the instructions for your operating system below: + +### [Windows](#tab/windows) + +1. Open a browser and navigate to [https://code.visualstudio.com/](https://code.visualstudio.com/). +2. Click the *Download for Windows* button to start the download of the installer. +3. Once the download finishes, run the installer. + +### [macOS](#tab/macos) + +1. Open a web browser and navigate to [https://code.visualstudio.com/](https://code.visualstudio.com/). +2. Click the *Download for macOS* button to start the download of the *.zip* archive. +3. Once the download finishes, double click the *.zip* archive to extract the *Visual Studio Code.app* application package +4. Drag-and-drop the *Visual Studio Code.app* application package into your *Application* folder to make it available in the macOS *LaunchPad*. + +### [Linux](#tab/linux) + +1. Open a web browser and navigate to [https://code.visualstudio.com/](https://code.visualstudio.com/). +2. Click the *.deb* download button to download the package for Debian based Linux distributions, or the *.rpm* download button for Red Hat based Linux distributions. +3. Once the download finishes, open the package downloaded to install. + +--- + +## Install the C# Dev Kit Extension + +For C# development using VSCode, it is recommended to use the official *[C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit)* extension provided by Microsoft. Installing this extension will add additional features to VSCode such as a project system and *Solution Explorer* for C# projects. It also provides code editing features such as syntax highlighting, code completion, code navigation, refactoring, NuGet package management, and debugging tools. + +> [!NOTE] +> The *Solution Explorer* panel is a hierarchical view provided by the C# Dev Kit extension that displays your solution structure similar to Visual Studio's Solution Explorer. +> +> When you open a workspace in Visual Studio Code containing a .NET solution file (*.sln*), the *Solution Explorer* panel automatically appears and loads your solution. From this panel, you can perform common operations like: +> +> * Adding new files. +> * Managing project references. +> * Viewing dependencies. +> * Executing build commands. +> +> For more information about the *Solution Explorer* panel in Visual Studio Code offered through the C# Dev Kit extension, you can view the official documentation in the [Project Management](https://code.visualstudio.com/docs/csharp/project-management) documentation. + +To install the C# Dev Kit extension, perform the following: + +1. Launch the *Visual Studio Code* application. +2. Open the *Extensions Panel* by clicking the icon in the *Activity Bar* on the left or choosing *View > Extensions* from the top menu. +3. Enter `C#` in the *Search Box* +4. Click install for the *C# Dev Kit* extension. + +| ![Figure 2-1: The C# Dev Kit Extension listed in Visual Studio Code](./images/devkit-extension.png) | +| :-------------------------------------------------------------------------------------------------: | +| **Figure 2-1: The C# Dev Kit Extension listed in Visual Studio Code** | + +> [!NOTE] +> When you search `C#` in the *Extension Panel* you may notice there is the C# Dev Kit extension and a base standard C# extension. When installing the C# Dev Kit extension, the base extension will also be installed as a requirement. + +## Installing the "MonoGame for VSCode" Extension + +Throughout this tutorial, we will be using the MonoGame Content Builder (MGCB) Editor to add content to the game. MonoGame offers an official extension for Visual Studio 2022 that allows you to double-click the *Content.mgcb* file to automatically open it in the MGCB Editor. While there is no official tool for VSCode, there is a an extension developed by community member r88 to provide similar functionality and is regularly used by the MonoGame developers themselves. We will be using that extension throughout this tutorial. + +To install it, with VSCode open: + +1. Open the *Extensions Panel* by clicking the icon in the *Activity Bar* on the left or choosing *View > Extensions* from the top menu. +2. Enter `MonoGame for VSCode` in the *Search Box* +3. Click install on the *MonoGame for VSCode* extension by r88. + +## Setup WINE for Effect Compilation (macOS and Linux Only) + +*Effect* (shader) compilation requires access to DirectX. This means it will not work natively on macOS and Linux systems, but it can be used through [WINE](https://www.winehq.org/). MonoGame provides a setup script that can be executed to setup the WINE environment. Below you can find the steps based on your operating system. To do this, follow the instructions for your operating system below: + +### [Windows](#tab/windows) + +> [!NOTE] +> Setting up WINE for effect compilation is not required for Windows + +### [macOS](#tab/macos) + +Open a new *Terminal* window and execute the following commands: + +```sh +brew install p7zip +brew install --cask wine-stable +wget -qO- https://monogame.net/downloads/net8_mgfxc_wine_setup.sh | bash +``` + +> [!NOTE] +> After performing these steps, a new folder called *.winemonogame* will be created in your home folder. If you ever wish to undo the setup performed by this script, you can simply delete this folder. + +### [Linux](#tab/linux) + +Open a new *Terminal* window and execute the following commands: + +```sh +sudo apt-get update && sudo apt-get install -y curl p7zip-full wine64 +wget -qO- https://monogame.net/downloads/net8_mgfxc_wine_setup.sh | bash +``` + +> [!NOTE] +> After performing these steps, a new folder called *.winemonogame* will be created in your home folder. If you ever wish to undo the setup performed by this script, you can simply delete this folder. + +--- + +## Creating Your First MonoGame Application + +With your development environment setup, it is time to create your first MonoGame application. + +1. Launch the VSCode application +2. Open the *Command Palette* by clicking *View > Command Palette* or by using the keyboard shortcut `CTRL+SHIFT+P` (`CMD+SHIFT+P` on Mac). +3. Type `.NET New Project` in the *Command Palette* and choose the *.NET New Project* command +4. Next, you will be presented with a list of the available .NET project templates. Enter `MonoGame` into the prompt to filter the project templates to only show the MonoGame ones, then choose the *MonoGame Cross-Platform Desktop Application* project template. + + > [!NOTE] + > If the MonoGame templates are not showing up, then you skipped the step to install the templates, exit the project creation and click `Terminal -> New Terminal` in VSCode and run the following command: + > + > ```sh + > dotnet new install MonoGame.Templates.CSharp + > ``` + +5. After choosing the template, a dialog window will appear asking you to choose a location to save the project, this is a folder where your projects will will be created by default. +6. Next, you will be prompted to enter a name for the project. Enter the name `DungeonSlime`, which will create your project in a new folder with the same name. +7. If this is your first time creating your project, you will be asked to choose a solution format, simply select the default `.sln` option and click `Next` to continue. (This does not occur with subsequent projects) +8. Finally, select the *Create Project* prompt. + +After selecting *Create Project*, a new C# project will be generated based on the chosen MonoGame template and opened automatically in VSCode. + +| ![Figure 2-2: A new MonoGame project after being created in Visual Studio Code](./images/vscode.png) | +| :--------------------------------------------------------------------------------------------------: | +| **Figure 2-2: A new MonoGame project after being created in Visual Studio Code** | + +Now that we have the project created, press the `F5` key on your keyboard, or choose *Run > Start Debugging* from the top menu. If prompted for a configuration, choose *C#*. The project will compile and run, displaying a screen similar to the following: + +| ![Figure 2-3: The default MonoGame cornflower blue game window](./images/game-window.png) | +| :---------------------------------------------------------------------------------------: | +| **Figure 2-3: The default MonoGame cornflower blue game window** | + +Be amazed, the default MonoGame Cornflower Blue game window. You have just created your very first MonoGame application. While there is not much happening here visually, there is a log going on behind the scenes that the MonoGame framework is handling for you. When you ran the application, the following occurred: + +1. The application started +2. The game window was created and graphics were initialized +3. A loop is entered which performs the following over and over, until the game is told to exit: + 1. The game is updated + 2. The game is rendered to the window + +You can exit the game at any time by pressing the `Esc` key on your keyboard. + +> [!NOTE] +> Above, I mentioned that a loop is entered. This is commonly referred to as the *game loop*, which we will discuss in more detail in the next chapter. The reason the application enters this loop is because game applications work differently than traditional desktop applications, such as your web browser. +> +> Desktop applications are event based, meaning once loaded, they do not do much at all while waiting for input from the user. They respond to user interactions and redraw the window only when necessary. +> +> In games, things are always happening, such as objects moving around like the player or particle effects. To handle this, games implement a loop structure that runs continuously, first calling a method to **`update`** the game logic, and then a **`draw`** method to render the current frame, until it is told to exit. + +## Conclusion + +In this chapter, you accomplished the following: + +* You setup your operating system to develop .NET applications by installing the .NET SDK +* You installed the MonoGame project templates. +* You installed VSCode and the necessary extension to develop C# applications with VSCode +* You created and ran your first MonoGame project. + +Now that your development environment is setup and ready to go, you can dive in and start building your first game. In the next chapter, we will cover the contents of the `Game1.cs` file that was included in the MonoGame project you just created. + +## Test Your Knowledge + +1. What are two advantages of MonoGame being a framework rather than an engine? + + :::question-answer + Any two of the following: + + * Flexibility: Developers can use their preferred development tools + * Integration: MonoGame easily integrates with other .NET libraries + * Cross-platform Development: Projects can be developed on Windows, macOS, or Linux + * Version Control Friendly: The code-first approach works well with systems like Git + + ::: + +2. What is the primary reason that game applications implement a *game loop* structure instead of using an event-based approach like traditional desktop applications? + + :::question-answer + Game application implement a *game loop* structure because games need to continuously update and render, event when there is no user input. In games, objects might be moving, animations playing, and physics calculating regardless of user interaction, requiring constant updating and rendering until the game is told to exit. + ::: + +3. What is the color of the game window when you run a MonoGame project for the first time? + + :::question-answer + Cornflower Blue + ::: diff --git a/articles/tutorials/building_2d_games/03_the_game1_file/images/monogame-lifecycle.png b/articles/tutorials/building_2d_games/03_the_game1_file/images/monogame-lifecycle.png new file mode 100644 index 00000000..0e748306 Binary files /dev/null and b/articles/tutorials/building_2d_games/03_the_game1_file/images/monogame-lifecycle.png differ diff --git a/articles/tutorials/building_2d_games/03_the_game1_file/images/monogame-lifecycle.svg b/articles/tutorials/building_2d_games/03_the_game1_file/images/monogame-lifecycle.svg new file mode 100644 index 00000000..ab108733 --- /dev/null +++ b/articles/tutorials/building_2d_games/03_the_game1_file/images/monogame-lifecycle.svg @@ -0,0 +1,465 @@ + + + +Game LoopYesLoadContent()Initialize()UpdateExit?DrawExit()No diff --git a/articles/tutorials/building_2d_games/03_the_game1_file/images/monogame-orange.png b/articles/tutorials/building_2d_games/03_the_game1_file/images/monogame-orange.png new file mode 100644 index 00000000..643ff341 Binary files /dev/null and b/articles/tutorials/building_2d_games/03_the_game1_file/images/monogame-orange.png differ diff --git a/articles/tutorials/building_2d_games/03_the_game1_file/images/solitaire.webp b/articles/tutorials/building_2d_games/03_the_game1_file/images/solitaire.webp new file mode 100644 index 00000000..3d32c291 Binary files /dev/null and b/articles/tutorials/building_2d_games/03_the_game1_file/images/solitaire.webp differ diff --git a/articles/tutorials/building_2d_games/03_the_game1_file/index.md b/articles/tutorials/building_2d_games/03_the_game1_file/index.md new file mode 100644 index 00000000..efbf095b --- /dev/null +++ b/articles/tutorials/building_2d_games/03_the_game1_file/index.md @@ -0,0 +1,130 @@ +--- +title: "Chapter 03: The Game1 File" +description: Explore the contents of the Game1 file generated when creating a new MonoGame project. +--- + +After you created a new MonoGame project using the *MonoGame Cross-Platform Desktop Application* template in [Chapter 02](../02_getting_started/index.md#creating-your-first-monogame-application), you will notice the generated files and project structure that serve as a starting point for your game application. While MonoGame offers different templates based on target platform, all projects will contain the `Game1.cs` file. + +## Exploring the Game1 Class + +At the core of a MonoGame project is the [**Game**](xref:Microsoft.Xna.Framework.Game) class. This class handles the initialization of graphics services, initialization of the game, loading content, updating, and rendering the game. When you create a new MonoGame project, this [**Game**](xref:Microsoft.Xna.Framework.Game) class is implemented as the `Game1` class that you can customize as needed for your specific game. + +> [!TIP] +> While the default template names the class `Game1`, you are free to rename it to something more appropriate for your project. However, for consistency, the documentation will continue to refer to it as `Game1`. + +Locate the `Game1.cs` file that was generated when you created the MonoGame project and open it. The default content will be: + +[!code-csharp[](./snippets/game1.cs)] + +This class provides the following structure: + +1. **Graphics and Rendering**: The class declares two core graphics components; the [**GraphicsDeviceManager**](xref:Microsoft.Xna.Framework.GraphicsDeviceManager) for interacting with the Graphics Processing Unit (GPU) and the [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch) for 2D rendering. +2. **Initialization**: The constructor and [**Initialize**](xref:Microsoft.Xna.Framework.Game.Initialize) method handle the game's setup sequence. +3. **Content Loading**: The [**LoadContent**](xref:Microsoft.Xna.Framework.Game.LoadContent) method manages game asset loading during startup. +4. **Game Loop**: The *game loop* consists of the [**Update**](xref:Microsoft.Xna.Framework.Game.Update(Microsoft.Xna.Framework.GameTime)) method for game logic and the [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)) method for rendering, running continuously until the game is told to exit. + +*Figure 3-1* below shows the lifecycle of a MonoGame game including the [**Update**](xref:Microsoft.Xna.Framework.Game.Update(Microsoft.Xna.Framework.GameTime)) and [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)) methods that make up the *game loop*. + +| ![Figure 3-1: Lifecycle of a MonoGame game](./images/monogame-lifecycle.png) | +| :--------------------------------------------------------------------------: | +| **Figure 3-1: Lifecycle of a MonoGame game** | + +## Graphics and Rendering + +The graphics pipeline in monogame starts with two components: the [**GraphicsDeviceManager**](xref:Microsoft.Xna.Framework.GraphicsDeviceManager) and [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch). + +[!code-csharp[](./snippets/game1.cs?start=9&end=10)] + +The [**GraphicsDeviceManager**](xref:Microsoft.Xna.Framework.GraphicsDeviceManager) initializes and the connection to the graphics hardware. It handles tasks such as setting the screen resolution, toggling between fullscreen and windowed mode, and managing the [**GraphicsDevice**](xref:Microsoft.Xna.Framework.Graphics.GraphicsDevice), which is the interface between your game and the Graphics Processing Unit (GPU) the game is running on. The [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch) optimizes 2D rendering by batching similar draw calls together, improving draw performance when rendering multiple sprites. + +## Initialization + +MonoGame's initialization process for your game follows a specific sequence. The constructor runs first, which handles basic setup like creating the [**GraphicsDeviceManager**](xref:Microsoft.Xna.Framework.GraphicsDeviceManager), setting the content directory, and the visibility of the mouse. + +[!code-csharp[](./snippets/game1.cs?start=12&end=17)] + +After that, the [**Initialize**](xref:Microsoft.Xna.Framework.Game.Initialize) method executes, providing a dedicated place for additional configuration and initializations. + +[!code-csharp[](./snippets/game1.cs?start=19&end=22)] + +This separation allows you to perform setup tasks in a logical order; core systems in the constructor and game-specific initializations in the [**Initialize**](xref:Microsoft.Xna.Framework.Game.Initialize) method. The call to `base.Initialize()` should never be removed, as this is where the graphics device is initialized for the target platform. + +> [!TIP] +> You may be wondering why there is an [**Initialize**](xref:Microsoft.Xna.Framework.Game.Initialize) method instead of performing all initializations in the constructor. The [**Initialize**](xref:Microsoft.Xna.Framework.Game.Initialize) method is a `virtual` method that is overridden, and [it is advised to not call overridable methods from within a constructor](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2214), as this can lead to unexpected states in object constructor when called. Additionally, when the constructor is called, the base constructor will instantiate properties and services based on the target platform that may be needed first before performing initializations for the game itself. + +## Content Loading + +The [**LoadContent**](xref:Microsoft.Xna.Framework.Game.LoadContent) method serves as the place for asset management. Here you can load textures, sound effects, music, and other game assets. We will cover loading assets in the coming chapters as we discuss each asset type that can be loaded. In a new project, the only task it performs is initializing a new instance of the [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch). + +[!code-csharp[](./snippets/game1.cs?start=24&end=27)] + +This method is only call once during the startup of the game, but *when* it is called can be a little confusing at first. In the [**Initialize**](xref:Microsoft.Xna.Framework.Game.Initialize) method shown above, when the `base.Initialize` call is executed, the final task it performs is calling the [**LoadContent**](xref:Microsoft.Xna.Framework.Game.LoadContent) method. This means any initializations you need to perform that have a dependency on assets being loaded should be done *after* the `base.Initialize` call and not *before* it. + +## The Game Loop + +MonoGame implements a *game loop* by calling [**Update**](xref:Microsoft.Xna.Framework.Game.Update(Microsoft.Xna.Framework.GameTime)) and [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)) over and over until the game is told to exit. Recall at the end of [Chapter 02](../02_getting_started/index.md#creating-your-first-monogame-application) when you ran the project for the first time, I mentioned that there is a lot going on behind the scenes? This game loop is what I was referring to. + +MonoGame is executing the [**Update**](xref:Microsoft.Xna.Framework.Game.Update(Microsoft.Xna.Framework.GameTime)) method and then the [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)) method 60 times per second. + +[!code-csharp[](./snippets/game1.cs?start=29&end=42)] + +The [**Update**](xref:Microsoft.Xna.Framework.Game.Update(Microsoft.Xna.Framework.GameTime)) method at the moment is not doing much, only checking for input from a controller or keyboard to determine if the game should exit. However, the [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)) method is doing more than what it appears to at first glance. + +The first line is executing the [**Clear**](xref:Microsoft.Xna.Framework.Graphics.GraphicsDevice.Clear(Microsoft.Xna.Framework.Color)) method of the [**GraphicsDevice**](xref:Microsoft.Xna.Framework.Graphics.GraphicsDevice) property using the color [**CornflowerBlue**](xref:Microsoft.Xna.Framework.Color.CornflowerBlue). Recall that the [**GraphicsDevice**](xref:Microsoft.Xna.Framework.Graphics.GraphicsDevice) object is your direct interface between the game and what is rendered to the screen. Every time the [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)) method is called, this line of code of erasing the contents of the game window and refilling it with the color specified. Without clearing the contents of the screen first, every draw call would draw the new frame render over top of the previous render, and you'd end up with something like the old solitaire win screen + +| ![Figure 3-2: Windows XP Solitaire Win Screen](./images/solitaire.webp) | +| :---------------------------------------------------------------------: | +| **Figure 3-2: Windows XP Solitaire Win Screen** | + +While this can make for a neat effect, it is not something you want all the time. So, the screen is cleared and refilled with a solid color. + +> [!NOTE] +> You can test this yourself by modifying the code to use a different color, such as [**Color.MonoGameOrange**](xref:Microsoft.Xna.Framework.Color.MonoGameOrange). (yes, there is a MonoGame Orange color). +> +> [!code-csharp[](./snippets/draw.cs?highlight=3)] +> +> After making this change and running the game, the screen is cleared to the MonoGame Orange color. +> +> | ![Figure 3-3: The game window clearing the screen using the MonoGame Orange color](./images/monogame-orange.png) | +> | :---: | +> | **Figure 3-3: The game window clearing the screen using the MonoGame Orange color** | + +Each time the game loops completes and the game is drawn to the screen, we call this a *frame*. So if MonoGame is running the game loop at 60 frames per second, that means it is performing and update and a render of each frame every 16ms. Notice that both the [**Update**](xref:Microsoft.Xna.Framework.Game.Update(Microsoft.Xna.Framework.GameTime)) and the [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)) methods both receive a parameter of the type [**GameTime**](xref:Microsoft.Xna.Framework.GameTime). The [**GameTime**](xref:Microsoft.Xna.Framework.GameTime) parameter provides a snapshot of the timing values for the game, including the amount of time that it took for the previous frame to execute. This is commonly referred to as the *delta time*. + +*Delta time* allows you to track time accurately for things such as animations and events based on *game time* and not the speed of the processor (CPU) on the machine running the game. While in ideal circumstances, the delta time will always be 16ms, there are any number of things that could cause a temporary slow down or hiccup in a frame, and using the delta time ensures that timing based events are always correct. + +## Conclusion + +In this chapter, you accomplished the following: + +- You read through the default code provided in a `Game1.cs` file created by a MonoGame template. +- You learned about the lifecycle of a MonoGame game project. +- You learned what a game loop is and how it is implemented in MonoGame. + +In the next chapter, you will start working with sprites and learn how to load and render them. + +## Test Your Knowledge + +1. Can the `Game1` class be renamed or is it required to be called `Game1` + + :::question-answer + It is not a requirement that it be called `Game1`. This is just the default name given to it by the templates when creating a new MonoGame game project. However, you cannot change the name of the *BASE* class `Game`, as this is a MonoGame construct. + ::: + +2. What is the [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch) used for? + + :::question-answer + The [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch) provides an optimized method of rendering 2D graphics, like sprites, onto the screen + ::: + +3. When is the [**LoadContent**](xref:Microsoft.Xna.Framework.Game.LoadContent) method executed and why is it important to know this? + + :::question-answer + [**LoadContent**](xref:Microsoft.Xna.Framework.Game.LoadContent) is executed during the `base.Initialize()` method call within the [**Initialize**](xref:Microsoft.Xna.Framework.Game.Initialize) method. It is important to know this because anything being initialized that is dependent on content loaded should be done **after** the `base.Initialize()` call and not **before**. + ::: + +4. How does MonoGame provide a *delta time* value? + + :::question-answer + Through the [**GameTime**](xref:Microsoft.Xna.Framework.GameTime) parameter that is given to both the [**Update**](xref:Microsoft.Xna.Framework.Game.Update(Microsoft.Xna.Framework.GameTime)) and the [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)) methods. + ::: diff --git a/articles/tutorials/building_2d_games/03_the_game1_file/snippets/draw.cs b/articles/tutorials/building_2d_games/03_the_game1_file/snippets/draw.cs new file mode 100644 index 00000000..56688f7e --- /dev/null +++ b/articles/tutorials/building_2d_games/03_the_game1_file/snippets/draw.cs @@ -0,0 +1,6 @@ +protected override void Draw(GameTime gameTime) +{ + GraphicsDevice.Clear(Color.MonoGameOrange); + + base.Draw(gameTime); +} diff --git a/articles/tutorials/building_2d_games/03_the_game1_file/snippets/game1.cs b/articles/tutorials/building_2d_games/03_the_game1_file/snippets/game1.cs new file mode 100644 index 00000000..da77cbe2 --- /dev/null +++ b/articles/tutorials/building_2d_games/03_the_game1_file/snippets/game1.cs @@ -0,0 +1,43 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; + +namespace DungeonSlime; + +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() + { + base.Initialize(); + } + + protected override void LoadContent() + { + _spriteBatch = new SpriteBatch(GraphicsDevice); + } + + protected override void Update(GameTime gameTime) + { + if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape)) + Exit(); + + base.Update(gameTime); + } + + protected override void Draw(GameTime gameTime) + { + GraphicsDevice.Clear(Color.CornflowerBlue); + + base.Draw(gameTime); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/04_creating_a_class_library/images/game-window.png b/articles/tutorials/building_2d_games/04_creating_a_class_library/images/game-window.png new file mode 100644 index 00000000..02ea4eee Binary files /dev/null and b/articles/tutorials/building_2d_games/04_creating_a_class_library/images/game-window.png differ diff --git a/articles/tutorials/building_2d_games/04_creating_a_class_library/images/with-class-library-diagram.svg b/articles/tutorials/building_2d_games/04_creating_a_class_library/images/with-class-library-diagram.svg new file mode 100644 index 00000000..8d0027d7 --- /dev/null +++ b/articles/tutorials/building_2d_games/04_creating_a_class_library/images/with-class-library-diagram.svg @@ -0,0 +1 @@ +Game CodeGame 1Game CodeGame 2Audio SystemInput SystemPhysics SystemClass Library diff --git a/articles/tutorials/building_2d_games/04_creating_a_class_library/images/without-class-library-diagram.svg b/articles/tutorials/building_2d_games/04_creating_a_class_library/images/without-class-library-diagram.svg new file mode 100644 index 00000000..5bc20460 --- /dev/null +++ b/articles/tutorials/building_2d_games/04_creating_a_class_library/images/without-class-library-diagram.svg @@ -0,0 +1 @@ +Audio SystemGame CodeInput SystemPhysics SystemGame 1Audio SystemGame CodeInput SystemPhysics SystemGame 2 diff --git a/articles/tutorials/building_2d_games/04_creating_a_class_library/index.md b/articles/tutorials/building_2d_games/04_creating_a_class_library/index.md new file mode 100644 index 00000000..d6dd00c7 --- /dev/null +++ b/articles/tutorials/building_2d_games/04_creating_a_class_library/index.md @@ -0,0 +1,226 @@ +--- +title: "04: Creating a Class Library" +description: "Learn how to create and structure a reusable MonoGame class library to organize game components and share code between projects." +--- + +One of the goals of this tutorial is to create reusable modules that you can use to jump start your next game project after this. Rather than starting from scratch each time, we will build a collection of game components you can take with you from project to project. + +In this chapter you will: + +- Learn about class libraries and their benefits for game development. +- Create a MonoGame class library project using templates. +- Add library references to your game project. +- Structure your library for reusability. +- Set up the foundation for creating shared game components. + +## What Is a Class Library + +Think of a class library like a toolbox for your game development. Just as a mechanic keeps their most-used tools in a toolbox they bring to every job, a class library stores code components you will want to use in multiple game projects. Instead of recreating these tools for each new game (or copying and pasting code), you organize them in one place where they are easy to find, use, and improve over time. + +The following diagrams show how this works: + +| ![Figure 4-1: Diagram displays the block for Game 1 on the left and Game 2 on the right. In this example, when not using a class library, code for input, physics and audio systems are duplicated between both game projects. If a bug is fixed in one system, the effort has to be duplicated in the same system in other game projects](./images/without-class-library-diagram.svg) | +| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 4-1: Diagram displays the block for Game 1 on the left and Game 2 on the right. In this example, when not using a class library, code for input, physics and audio systems are duplicated between both game projects. If a bug is fixed in one system, the effort has to be duplicated in the same system in other game projects** | + +| ![Figure 4-2: Diagram displays a block for a class library which contains common modules at the top, which are then shared between the two game projects below. If a bug is found in a module, fixing the bug will fix it across all game projects that use the class library](./images/with-class-library-diagram.svg) | +| :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 4-2: Diagram displays a block for a class library which contains common modules at the top, which are then shared between the two game projects below. If a bug is found in a module, fixing the bug will fix it across all game projects that use the class library** | + +> [!NOTE] +> A class library is a project type that compiles into a [Dynamic Link Library](https://learn.microsoft.com/en-us/windows/win32/dlls/dynamic-link-libraries) (DLL) instead of an executable. It contains reusable code that can be referenced by other projects, making it perfect for sharing common functionality across multiple games. + +## Why Create a Class Library? + +Creating a class library offers several important advantages, especially as your games grow more complex: + +1. **Reusability**: Instead of rewriting the same code for each new game project, you build it once in your library and reuse it everywhere. This is like creating a multi-tool that works across all your projects. +2. **Organization**: Your game code stays focused on the unique aspects of each game, while common functionality lives in the library. This keeps your project folder neat and makes code easier to find. +3. **Maintainability**: When you improve or fix a bug in your library code, all games using that library benefit automatically. This means fixing one bug once instead of in multiple places. +4. **Testing**: You can test your library code independently from any specific game. This helps ensure your core systems are solid before you build a game on top of them. + +As your library grows, you will accumulate a personal collection of well-tested modules that make starting new projects much faster. The modules we will create in this library will handle common game tasks like input, audio, sprites, and animations. + +## Adding the Class Library + +MonoGame offers the *MonoGame Game Library* project template to add a new class library project that is configured with the correct monoGame framework references. Using this template saves time and ensures compatibility with MonoGame projects. + +To use the template to add the class library, perform the following based on which development environment you are using: + +### [Visual Studio Code](#tab/vscode) + +To add the class library using the MonoGame Game Library project template in Visual Studio Code, perform the following: + +1. In the [*Solution Explorer*](../02_getting_started/index.md#install-the-c-dev-kit-extension) panel, right-click the *DungeonSlime* solution. +2. Chose *New Project* from the context menu. +3. Enter "MonoGame Game Library" and select it as the template to use. +4. Name the project "MonoGameLibrary". +5. When prompted for a location, use the default option, which will put the new project in a folder next to your game project. +6. Select "Create Project". + +### [Visual Studio 2022](#tab/vs2022) + +To add the class library using the MonoGame Game Library project template in Visual Studio 2022, perform the following: + +1. Right-click the *DungeonSlime* solution in the Solution Explorer panel. +2. Choose Add > New Project from the context menu. +3. Enter "MonoGame Game Library" in the search box, select that template, then click Next. +4. Name the project "MonoGameLibrary". +5. The location by default will put the new project in a folder next to your game project; you do not need to adjust this. +6. Click "Create". + +### [dotnet CLI](#tab/dotnetcli) + +To add the class library using the MonoGame Game Library project template with the dotnet CLI, perform the following: + +1. Open a new Command Prompt or Terminal window in the same folder as the *DungeonSlime.sln* solution file. +2. Enter the command `dotnet new mglib -n MonoGameLibrary` to create the project, placing it in a folder next to your game project. +3. Enter the command `dotnet sln DungeonSlime.sln add ./MonoGameLibrary/MonoGameLibrary.csproj` to add the newly created class library project to the *DungeonSlime.sln* solution file. + +--- + +## Adding a Reference To The Class Library + +Now that the game library project has been created, a reference to it needs to be added in our game project. Without adding a reference, our game project will be unaware of anything we add to the class library. To do this, perform the following based on which development environment you are using: + +### [Visual Studio Code](#tab/vscode) + +To add the game library project as a reference to the game project in Visual Studio Code: + +1. In the Solution Explorer panel, right-click the *DungeonSlime* project. +2. Choose "Add Project Reference" from the context menu. +3. Choose *MonoGameLibrary" from the available options. + +> [!TIP] +> The Solution Explorer panel in VSCode is provided by the C# Dev Kit extension that was installed in [Chapter 02](../02_getting_started/index.md#install-the-c-dev-kit-extension). If you do not see this panel, you can open it by +> +> 1. Opening the *Command Palette* (View > Command Palette). +> 2. Enter "Explorer: Focus on Solution Explorer View" and select the command. + +### [Visual Studio 2022](#tab/vs2022) + +To add the game library project as a reference to the game project in Visual Studio 2022: + +1. In the Solution Explorer panel, right-click the *DungeonSlime* project. +2. Select Add > Project Reference from the context menu. +3. Check the box for the *MonoGameLibrary* project. +4. Click Ok. + +### [dotnet CLI](#tab/dotnetcli) + +To add the game library project as a reference to the game project with the dotnet CLI: + +1. Open a new Command Prompt or Terminal window in the same folder as the *DungeonSlime.csproj* C# project file. +2. Enter the command `dotnet add ./DungeonSlime.csproj reference ../MonoGameLibrary/MonoGameLibrary.csproj`. This will add the *MonoGameLibrary* reference to the *DungeonSlime* game project. + +--- + +### Clean Up + +When using the *MonoGame Game Library* project template, the generated project contains file similar to a standard MonoGame game project, including a *dotnet-tools.json* manifest file, a *Content.mgcb* file, and a `Game1.cs` file. For the purposes of this tutorial, we will not need these. To clean these up, locate the following in the *MonoGameLibrary* project folder and delete them: + +1. The *.config/* folder. +2. The *Content/* folder +3. The `Game1.cs` file. + +> [!TIP] +> These files are needed in more advanced scenarios such as creating a central code base for game logic that is referenced by other projects of which each target different platforms such as desktop, mobile, and console. Creating a project structure of this type is out of scope for this tutorial. +> +> If you would like more information on this, Simon Jackson has written the article [Going cross-platform with MonoGame](https://darkgenesis.zenithmoon.com/going-cross-platform-with-monogame.html) which covers this in more detail. +> +> Also the `2D Start Kit` and `2D Blank Start Kit` templates provide you with a richer startup project targetting all platforms, using a common Class Library to reuse code across them all. Although we recommend completing this tutorial first before tackling that beast. + +## Creating Our First Library Module + +We will create a class for our library called `Core`. This class will extend the MonoGame [**Game**](xref:Microsoft.Xna.Framework.Game) class and provide a starting point for game development with some common functionality built in. Creating this will also let us validate that our class library reference setup was correct. + +Create a new file called `Core.cs` in the *MonoGameLibrary* project and add the following code: + +[!code-csharp[](./snippets/core.cs)] + +The `Core` class provides the following features + +1. It extends the MonoGame [**Game**](xref:Microsoft.Xna.Framework.Game) class, so it inherits all of the base functionality. +2. It implements a [singleton pattern](https://en.wikipedia.org/wiki/Singleton_pattern) through the `Instance` property, ensure only one core exists. +3. It provides static access to the graphics device manager, the graphics device, the sprite batch, and the content manager. +4. It simplifies the game window setup with a constructor that handles common initializations. + +> [!NOTE] +> The `new` keyword in the property declaration `public static new GraphicsDevice GraphicsDevice` and `public static new ContentManager Content` is used to intentionally hide (or "shadow") the inherited `GraphicsDevice` and `Content` properties from the base `Game` class. This creates new properties with the same name but different accessibility (static vs. instance) in the derived class. +> +> When you access `Core.GraphicsDevice` or `Core.Content` you will be using this static properties, while `base.GraphicsDevice` or `base.Content` within instance methods of the `Core` class would still access the original property. This pattern allows us to provide convenient static access to the graphics device and content manager throughout our game without having to reference the Core instance every time. + +This approach provides a consistent foundation for all our games, handling common setup tasks and providing convenient access to core functionality. + +> [!NOTE] +> As this tutorial progress, we will be coming back to this `Core` class to add more to it. + +## Updating Our Game to Use the Core Class + +Now that we have our `Core` class, we can modify our game project to use it. Doing this will also help ensure that the project references were setup correctly. + +Open the `Game1.cs` file and make the following changes: + +[!code-csharp[](./snippets/game1.cs?highlight=4,8,10,22-25)] + +The key changes made here are: + +1. Adding `using MonoGameLibrary;` directive to reference our library. +1. Removed the [**GraphicsDeviceManager**](xref:Microsoft.Xna.Framework.GraphicsDeviceManager) and [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch) fields, these are now supplied through the `Core` class. +1. Changed `Game1` class to inherit from `Core` instead of `Game`. +1. Updated the constructor to call the `Core` base constructor with our game configuration. + +Running the game now will show the same window as before, only now it is at a 1280x720 resolution as per the configuration and it is using the `Core` class from our library. This may not seem like a big change visually, but it demonstrates how our library can simplify and standardize game initializations. + +> [!NOTE] +> If you get any additional prompts when starting the project, simply accept the defaults. Adding the Game Library gives the compiler more projects to choose from when starting the project, however, we can only "run" the Game Project, libraries are not executables. + +| ![Figure 4-3: The game window at 1280x720 with the title Dungeon Slime](./images/game-window.png) | +| :-----------------------------------------------------------------------------------------------: | +| **Figure 4-3: The game window at 1280x720 with the title Dungeon Slime** | + +> [!IMPORTANT] +> If you receive an error stating that the following: +> +> *The type or namespace name 'Core' could not be found (are you missing a using directive or an assembly reference?)* +> +> This means either you forgot to add the `using MonoGameLibrary;` using directive to the top of the `Game1.cs` class file, or you did not add the project reference correctly. Ensure that the project reference was added correctly by revisiting the [Add a Reference to the Class Library](#adding-a-reference-to-the-class-library) section above and that you added the using directive. + +## Conclusion + +In this chapter, you accomplished the following: + +- Learned about class libraries and their advantages for game development: + - Code reusability across projects + - Better organization and separation of concerns + - Improved maintainability + - Easier testing +- Created a MonoGame class library project +- Added the library as a reference to your game project +- Created your first reusable component and referenced and used it in the game project. + +In the next chapter, we will learn about the Content Pipeline and how to load game assets. + +## Test Your Knowledge + +1. What are the main benefits of using a class library for game development? + + :::question-answer + The main benefits are: + - **Reusability**: Code can be easily shared between different game projects + - **Organization**: Separates reusable code from game-specific code + - **Maintainability**: Changes to shared code benefit all games using the library + - **Testing**: Library code can be tested independently of specific games + ::: + +2. Why should you use the MonoGame Game Library template instead of a standard class library template? + + :::question-answer + The MonoGame Game Library template automatically configures the correct MonoGame framework references and ensures compatibility with MonoGame projects, saving time and preventing potential setup issues. + ::: + +3. What happens if you do not add a reference to your class library in your game project? + + :::question-answer + > Without adding a reference, your game project will be unaware of any code in the class library. You wo not be able to use any of the classes or components from the library in your game. + ::: diff --git a/articles/tutorials/building_2d_games/04_creating_a_class_library/snippets/core.cs b/articles/tutorials/building_2d_games/04_creating_a_class_library/snippets/core.cs new file mode 100644 index 00000000..581bd849 --- /dev/null +++ b/articles/tutorials/building_2d_games/04_creating_a_class_library/snippets/core.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; + +namespace MonoGameLibrary; + +public class Core : Game +{ + internal static Core s_instance; + + /// + /// Gets a reference to the Core instance. + /// + public static Core Instance => s_instance; + + /// + /// Gets the graphics device manager to control the presentation of graphics. + /// + public static GraphicsDeviceManager Graphics { get; private set; } + + /// + /// Gets the graphics device used to create graphical resources and perform primitive rendering. + /// + public static new GraphicsDevice GraphicsDevice { get; private set; } + + /// + /// Gets the sprite batch used for all 2D rendering. + /// + public static SpriteBatch SpriteBatch { get; private set; } + + /// + /// Gets the content manager used to load global assets. + /// + public static new ContentManager Content { get; private set; } + + /// + /// Creates a new Core instance. + /// + /// The title to display in the title bar of the game window. + /// The initial width, in pixels, of the game window. + /// The initial height, in pixels, of the game window. + /// Indicates if the game should start in fullscreen mode. + public Core(string title, int width, int height, bool fullScreen) + { + // Ensure that multiple cores are not created. + if (s_instance != null) + { + throw new InvalidOperationException($"Only a single Core instance can be created"); + } + + // Store reference to engine for global member access. + s_instance = this; + + // Create a new graphics device manager. + Graphics = new GraphicsDeviceManager(this); + + // Set the graphics defaults + Graphics.PreferredBackBufferWidth = width; + Graphics.PreferredBackBufferHeight = height; + Graphics.IsFullScreen = fullScreen; + + // Apply the graphic presentation changes + Graphics.ApplyChanges(); + + // Set the window title + Window.Title = title; + + // Set the core's content manager to a reference of hte base Game's + // content manager. + Content = base.Content; + + // Set the root directory for content + Content.RootDirectory = "Content"; + + // Mouse is visible by default + IsMouseVisible = true; + } + + protected override void Initialize() + { + base.Initialize(); + + // Set the core's graphics device to a reference of the base Game's + // graphics device. + GraphicsDevice = base.GraphicsDevice; + + // Create the sprite batch instance. + SpriteBatch = new SpriteBatch(GraphicsDevice); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/04_creating_a_class_library/snippets/game1.cs b/articles/tutorials/building_2d_games/04_creating_a_class_library/snippets/game1.cs new file mode 100644 index 00000000..a6bc512e --- /dev/null +++ b/articles/tutorials/building_2d_games/04_creating_a_class_library/snippets/game1.cs @@ -0,0 +1,47 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary; + +namespace DungeonSlime; + +public class Game1 : Core +{ + public Game1() : base("Dungeon Slime", 1280, 720, false) + { + + } + + protected override void Initialize() + { + // TODO: Add your initialization logic here + + base.Initialize(); + } + + protected override void LoadContent() + { + // TODO: use this.Content to load your game content here + + base.LoadContent(); + } + + 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); + } +} diff --git a/articles/tutorials/building_2d_games/05_content_pipeline/images/add-file-popup.png b/articles/tutorials/building_2d_games/05_content_pipeline/images/add-file-popup.png new file mode 100644 index 00000000..f5202a74 Binary files /dev/null and b/articles/tutorials/building_2d_games/05_content_pipeline/images/add-file-popup.png differ diff --git a/articles/tutorials/building_2d_games/05_content_pipeline/images/content-pipeline-workflow-full.png b/articles/tutorials/building_2d_games/05_content_pipeline/images/content-pipeline-workflow-full.png new file mode 100644 index 00000000..4be30284 Binary files /dev/null and b/articles/tutorials/building_2d_games/05_content_pipeline/images/content-pipeline-workflow-full.png differ diff --git a/articles/tutorials/building_2d_games/05_content_pipeline/images/content-pipeline-workflow-full.svg b/articles/tutorials/building_2d_games/05_content_pipeline/images/content-pipeline-workflow-full.svg new file mode 100644 index 00000000..5ea48230 --- /dev/null +++ b/articles/tutorials/building_2d_games/05_content_pipeline/images/content-pipeline-workflow-full.svg @@ -0,0 +1,773 @@ + +MonoGame Content Pipeline WorkflowMGCB EditorContent.mgcbAudioImagesFontsEffectsModelsSource FilesMonoGame.Content.Builder.TasksXnbXnbXnbXnbXnbCompiled AssetsContentManagerHigh-Level View AaAa diff --git a/articles/tutorials/building_2d_games/05_content_pipeline/images/logo-drawn.png b/articles/tutorials/building_2d_games/05_content_pipeline/images/logo-drawn.png new file mode 100644 index 00000000..3e330c62 Binary files /dev/null and b/articles/tutorials/building_2d_games/05_content_pipeline/images/logo-drawn.png differ diff --git a/articles/tutorials/building_2d_games/05_content_pipeline/images/logo.png b/articles/tutorials/building_2d_games/05_content_pipeline/images/logo.png new file mode 100644 index 00000000..1509036c Binary files /dev/null and b/articles/tutorials/building_2d_games/05_content_pipeline/images/logo.png differ diff --git a/articles/tutorials/building_2d_games/05_content_pipeline/images/mgcb-editor-icon.png b/articles/tutorials/building_2d_games/05_content_pipeline/images/mgcb-editor-icon.png new file mode 100644 index 00000000..33fe5233 Binary files /dev/null and b/articles/tutorials/building_2d_games/05_content_pipeline/images/mgcb-editor-icon.png differ diff --git a/articles/tutorials/building_2d_games/05_content_pipeline/images/mgcb-editor.png b/articles/tutorials/building_2d_games/05_content_pipeline/images/mgcb-editor.png new file mode 100644 index 00000000..5ad01f76 Binary files /dev/null and b/articles/tutorials/building_2d_games/05_content_pipeline/images/mgcb-editor.png differ diff --git a/articles/tutorials/building_2d_games/05_content_pipeline/images/mgcb-logo-added.png b/articles/tutorials/building_2d_games/05_content_pipeline/images/mgcb-logo-added.png new file mode 100644 index 00000000..4af7ea4f Binary files /dev/null and b/articles/tutorials/building_2d_games/05_content_pipeline/images/mgcb-logo-added.png differ diff --git a/articles/tutorials/building_2d_games/05_content_pipeline/images/new-file-popup.png b/articles/tutorials/building_2d_games/05_content_pipeline/images/new-file-popup.png new file mode 100644 index 00000000..9683fd32 Binary files /dev/null and b/articles/tutorials/building_2d_games/05_content_pipeline/images/new-file-popup.png differ diff --git a/articles/tutorials/building_2d_games/05_content_pipeline/images/new-folder-popup.png b/articles/tutorials/building_2d_games/05_content_pipeline/images/new-folder-popup.png new file mode 100644 index 00000000..6a5b499f Binary files /dev/null and b/articles/tutorials/building_2d_games/05_content_pipeline/images/new-folder-popup.png differ diff --git a/articles/tutorials/building_2d_games/05_content_pipeline/index.md b/articles/tutorials/building_2d_games/05_content_pipeline/index.md new file mode 100644 index 00000000..d91157d7 --- /dev/null +++ b/articles/tutorials/building_2d_games/05_content_pipeline/index.md @@ -0,0 +1,293 @@ +--- +title: "Chapter 05: Content Pipeline" +description: Learn the advantages of using the Content Pipeline to load assets and go through the processes of loading your first asset +--- + +Every game has assets; images to represent the visual graphics to players, audio to provide sound effects and background music, fonts to render text with, and much more. These assets start out as raw files (e.g. *.png* image files or *.mp3* audio files), which you will need to load into the game to use. + +## Loading Assets + +Loading assets can be done during runtime directly from file, or it can be loaded through the **Content Pipeline** Both of these methods are two sides of the same coin and there are trade offs to each approach. + +For instance, to load an image file directly at runtime, you would need to: + +1. Add the image file to your project. +2. Configure the project to copy the image file on build to the build output folder. +3. Load the image file as a texture at runtime using the [**Texture2D.FromFile**](xref:Microsoft.Xna.Framework.Graphics.Texture2D.FromFile(Microsoft.Xna.Framework.Graphics.GraphicsDevice,System.String)) method. + +> [!IMPORTANT] +> A big disadvantage to loading an image file as a texture directly, is when that when it loads it, it does so in its compressed format such as *.png* or *.jpg*. These compression formats are not understood by a Graphics Processing Unit (GPU); they will need to be decompressed into raw bytes as a format the GPU does understand before it can store the data. Doing this can potentially leave a larger memory footprint for your assets. You will also need to handle how different compression formats work on the platform you are targeting such as desktops, mobile, and consoles. + +On the other side of this coin, MonoGame offers the **Content Pipeline**; a workflow for managing assets. The workflow is made up of a set of tools and utilities that are automatically added by default when you create a new MonoGame project using the MonoGame project templates. To use this workflow, you need to: + +1. Add the asset file to your content project (*Content.mgcb* file) using the *MonoGame Content Builder Editor* (MGCB Editor). +2. Perform a project build. Doing this, the *MonoGame.Content.Builder.Tasks* NuGet reference will compile the assets defined in the content project, optimized for the target platform, and automatically copy them to the game project build folder. +3. Load the compiled asset at runtime using the [**ContentManager**](xref:Microsoft.Xna.Framework.Content.ContentManager). + +For the same amount of steps, you also get the benefit of the assets being pre-processed and compiled to an optimized format for the target platform. For instance, image files can be compiled using [DXT compression](https://en.wikipedia.org/wiki/S3\_Texture\_Compression), which is a format that is understood by GPUs without needing to be decompressed first, reducing the memory footprint. + +> [!NOTE] +> For more information on the benefits of compiling assets and what optimizations it can offer, see the [Content Pipeline](../../../getting_started/content_pipeline/index.md) documentation. + +For this tutorial series, we are going to focus on using the content pipeline workflow to load assets. Doing this will get you as the developer accustomed to using the content pipeline tools and also give the benefits of having assets precompiled to optimized formats. + +## The MGCB Editor + +As mentioned previously, the content pipeline workflow in MonoGame is made up of a set of tools that come with every new MonoGame project. At the center of this workflow is the MGCB Editor; a graphical tool for managing your game's content. + +Opening the MGCB Editor can be done in different ways depending on which IDE and development environment you have. Choose the one you are using below to open the MGCB Editor so we can explore its interface: + +### [Visual Studio Code](#tab/vscode) + +To open the *Content.mgcb* content project file in the MGCB Editor with Visual Studio Code, you can use the *MonoGame for VSCode* extension. You should have installed this extension in [Chapter 02](../02_getting_started/index.md#installing-the-monogame-for-vscode-extension). With this extension install, anytime you have a code file open, you will see the MonoGame logo in the top-right of the code window like below: + +| ![Figure 5-1: MonoGame for VSCode extension icon](./images/mgcb-editor-icon.png) | +| :------------------------------------------------------------------------------: | +| **Figure 5-1: MonoGame for VSCode extension icon** | + +Clicking the MonoGame logo here will open the *Content.mgcb* content project file from the current project in the MGCB Editor. + +### [Visual Studio 2022](#tab/vs2022) + +To open the *Content.mgcb* content project file in the MGCB Editor with Visual Studio 2022, you can use the *MonoGame Framework C# project templates* extension. Despite the name, this extension does more than just install the MonoGame project templates. With this extension installed, simply double-click the *Content.mgcb* content project file in the Solution Explorer panel and it will open it in the MGCB Editor. + +### [dotnet CLI](#tab/dotnetcli) + +To open the *Content.mgcb* content project file in the MGCB Editor using the dotnet CLI commands, perform the following: + +1. Open a new Command Prompt or Terminal window in the same folder as the *DungeonSlime.csproj* project file (your main game project). +2. Enter the following dotnet CLI command + + ```sh + dotnet mgcb-editor ./Content/Content.mgcb` + ``` + +--- + +> [!TIP] +> If for any reason the MGCB editor fails to load or you are hit with MGCB errors when you build your project, it is likely the MGCB references from the `dotnet-tools.json` configuration located in your projects `.config` folder have not been loaded/initialized. +> +> To correct this, simply run the following from a terminal/command prompt in your projects directory (there the `.config` folder is located) +> +> ```sh +> dotnet tool restore +> ``` +> +> This should restore the tool with the version that is configured. If at any time you update your `dotnet-tools.json` configuration, e.g. when upgrading to a newer version of MonoGame, **you will need to run this command again** + +| ![Figure 5-2: MonoGame Content Builder Editor (MGCB Editor) Window](./images/mgcb-editor.png) | +| :-------------------------------------------------------------------------------------------: | +| **Figure 5-2: MonoGame Content Builder Editor (MGCB Editor) Window** | + +In *Figure 5-2* above, you can see the user interface for the MGCB Editor: + +- **Toolbar**: Contains icon buttons for common actions such as creating new items, opening files, saving changes, and building content. +- **Project Panel**: Located on the left of the MGCB Editor, displays a hierarchical tree view of all content items added to the content project. The root node *Content* represents the root of the content project. +- **Properties Panel**: Located on the bottom left of the MGCB Editor, shows the properties of the currently selected item in the project panel. The properties available are based on the item type selected. +- **Build Output Panel**: The large area to the right side outputs build messages, warnings, and errors when content is processed. + +### Creating Folders to Organize Content + +Organizing your game assets into folders helps keep your content project manageable as it grows. For now, we will add a new folder that will hold the image assets we will add to the game throughout this tutorial series. In the MGCB Editor: + +1. In the Project Panel, select the root `Content` node. +2. Right-click it and choose `Add > New Folder...` from the context menu. +3. Type `images` for the folder name and click the `Ok` button. + +| ![Figure 5-3: New folder pop-up](./images/new-folder-popup.png) | +| :-------------------------------------------------------------: | +| **Figure 5-3: New folder pop-up** | + +You have now created a folder that will help organize the game's image assets. As we continue through this tutorial series, we will be adding additional folders for organization of content such as audio, fonts, and effects. + +> [!NOTE] +> If you try to add a new folder that already exists in the file system but is not showing in the MGCB editor, you will get an error. Either remove the folder or use `Add Existing Folder` instead. + +### Adding Your First Asset + +Now that we have a folder structure, we can add our first image asset to the project. For this example, we will use the MonoGame logo. Perform the following + +1. First, download the MonoGame logo by right-clicking the following image and saving it as `logo.png` somewhere on your computer: + + | ![Figure 5-4: MonoGame Horizontal Logo](./images/logo.png) | + | :--------------------------------------------------------: | + | **Figure 5-4: MonoGame Horizontal Logo** | + +2. In the MGCB Editor, select the `images` folder you created earlier. +3. Right-click it and choose `Add > Existing Item...` from the context menu. +4. Navigate to the location of the `logo.png` file you just downloaded and select it. +5. Click the `Open` button +6. When prompted in the add existing file popup, choose `Copy the file to the directory.` + + | ![Figure 5-5: Add existing file pop-up](./images/add-file-popup.png) | + | :------------------------------------------------------------------: | + | **Figure 5-5: Add existing file pop-up** | + +7. Save the changes to the content project by selecting `File > Save` from the top menu or pressing `CTRL+S`. + +| ![Figure 5-6: The logo image added to the content project in the MGCB Editor](./images/mgcb-logo-added.png) | +| :---------------------------------------------------------------------------------------------------------: | +| **Figure 5-6: The logo image added to the content project in the MGCB Editor** | + +> [!IMPORTANT] +> After changes have been made in the MGCB Editor, ensure that you save the changes. They are not automatically saved, though you will be warned if you close the editor and have not saved changes. You can tell that changes have not been saved by looking at the title bar of the MGCB editor window. If it has an '*' at the end of the window title, this means changes have not been saved. + +## Understanding the Content Pipeline Workflow + +Now that we have added our first asset, we can take a moment to understand what happens to this asset in the Content Pipeline workflow: + +1. You create source files for your game assets such as images, audio, fonts, effects, and 3D models. +2. Using the MGCB Editor, add these assets your content project (the `Content.mgcb` file). +3. When you perform a build of your project, the `MonoGame.Content.Builder.Task` NuGet reference will: + 1. Compile the assets defined in the content project using the **MonoGame Content Builder (MGCB)** tool into `.xnb` files. + 2. Copy the compiled `.xnb` files from the content project's build folder to your game project's build folder. +4. At runtime, you load the compiled assets using the [ContentManager](xref:Microsoft.Xna.Framework.Content.ContentManager). + +The following diagram demonstrates this workflow: + +| ![Figure 5-7: MonoGame Content Pipeline Workflow diagram showing the process flow from source files (Images, Audio, Fonts, Effects, Models) through the MGCB Editor to generate the Content.mgcb file, which is then processed by MonoGame.Content.Builder.Tasks to create compiled .xnb assets (Xnb formats for each type), which are finally loaded by the ContentManager at runtime](./images/content-pipeline-workflow-full.png) | +| :--------------------------------------------------------------------------------------------: | +| **Figure 5-7: MonoGame Content Pipeline Workflow diagram showing the process flow from source files (Images, Audio, Fonts, Effects, Models) through the MGCB Editor to generate the Content.mgcb file, which is then processed by MonoGame.Content.Builder.Tasks to create compiled .xnb assets (Xnb formats for each type), which are finally loaded by the ContentManager at runtime** | + +The Content Pipeline offers significant advantages: + +- Assets are pre-processed and optimized for your target platform. +- Image files can be compiled using formats like DXT compression, which GPU's understand natively. +- Asset loading is simplified and consistent across platforms. + +## The ContentManager Class + +To load assets in your game code, MonoGame provides the [**ContentManager**](xref:Microsoft.Xna.Framework.Content.ContentManager) class. The [**Game**](xref:Microsoft.Xna.Framework.Game) already has a [**Content**](xref:Microsoft.Xna.Framework.Game.Content) property which is a ready-to-ue instance of the [**ContentManager**](xref:Microsoft.Xna.Framework.Content.ContentManager) + +### ContentManager Methods + +They key methods for asset loading are: + +| Method | Returns | Description | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [**Load<T>(string)**](xref:Microsoft.Xna.Framework.Content.ContentManager.Load``1(System.String)) | `T` | Loads the assets of type `T` that has been processed by the content pipeline. | +| [**Unload**](xref:Microsoft.Xna.Framework.Content.ContentManager.Unload) | `void` | Unloads all assets that have been loaded by that content manager instance. | + +> [!TIP] +> When an asset is loaded for the first time, the [**ContentManager**](xref:Microsoft.Xna.Framework.Content.ContentManager) internally caches it. Loading the same asset again will return the cached version, avoiding extra disk reads. + +## Understanding Content Paths + +When loading content, you need to specify the path to the asset, minus the extension. This path is relative to the ContentManager's [**RootDirectory**](xref:Microsoft.Xna.Framework.Content.ContentManager.RootDirectory) property, which is set to **"Content"** by default in the `Game1` constructor. + +For example, with our newly added logo in the "images" folder,the path would be "images/logo" (without the file extension). The reason for this relates to the build process. When you build your project, the *MonoGame.Content.Builder.Tasks* NuGet reference [compiles your assets and copies them to the game's output folder](#understanding-the-content-pipeline-workflow). + +This creates a folder structure in your output directory similar to: + +```sh +DungeonSlime/ + └── bin/ + └── Debug/ + └── net8.0/ + ├── DungeonSlime.exe + └── Content/ + └── images/ + └── logo.xnb +``` + +> [!NOTE] +> Notice that the compile asset has an .xnb extension, but when loading the asset in code, you refer to it without any extension. + +## Loading and Displaying Your First Asset + +Now that we have the MonoGame logo added as an asset in the content project, we can modify the game to display the logo. In the *DungeonSlime* project open the `Game1.cs` file and perform the following: + +1. Add a field to store the logo texture by inserting this line after the class declaration: + + ```cs + // The MonoGame logo texture + private Texture2D _logo; + ``` + +2. In the [**LoadContent**](xref:Microsoft.Xna.Framework.Game.LoadContent) method, add this line to load the logo texture: + + ```cs + _logo = Content.Load("images/logo"); + ``` + +3. Finally, in the [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)) method, add these lines before the `base.Draw(gameTime);` call: + + ```cs + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw the logo texture + SpriteBatch.Draw(_logo, Vector2.Zero, Color.White); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + ``` + + > [!NOTE] + > We will go more into detail about the [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch) in the next chapter. + +The complete updated `Game1.cs` file should now look like this + +[!code-csharp[](./snippets/game1.cs?highlight=10-11,27,44-51)] + +Running the game now will show the MonoGame logo displayed in the upper-left corner of the game window. + +| ![Figure 5-8: The MonoGame logo drawn to the game window](./images/logo-drawn.png) | +| :--------------------------------------------------------------------------------: | +| **Figure 5-8: The MonoGame logo drawn to the game window** | + +## Adding Build-In Asset Types + +The MGCB Editor can also create certain built-in asset types. In this section we will explore these types and this functionality. If not already open, [open the MGCB Editor](#the-mgcb-editor) and perform the following: + +1. Select the `Content` node. +2. Right-click it and choose `Add > New Item...` from the context menu. +3. In the dialog that appears, you will see the available built-in types. + + | ![Figure 5-9: New file pop-up](./images/new-file-popup.png) | + | :---------------------------------------------------------: | + | **Figure 5-9: New file pop-up** | + +The available built-in types include: + +- **Effect (.fx)**: A shader file that creates custom visual effects by controlling how graphics are rendered on the GPU. +- **SpriteFont Description (.spritefont)**: A configuration file that defines how text will be displayed in your game, including character set and font properties. +- **Sprite Effect (.fx)**: A shader specifically designed for use with 2D sprites to create special visual effects. +- **Xml Content (.xml)**: A structured data file for storing game information like levels, dialogues, or configuration settings. +- **LocalizedSpriteFont Description (.spritefont)**: A configuration file for creating fonts with support for multiple languages. + +> [!NOTE] +> Each built-in asset type comes with a template that includes the minimum required structure and settings. + +For now, click the `Cancel` button on the new file dialog. We will explore these built-in types further in later chapters when we need them. + +## Conclusion + +In this chapter, you accomplished the following: + +- You learned about the advantages of loading assets using the **Content Pipeline**. +- You opened the MGCB Editor and explored its interface. +- You created a folder structure to organize your game assets. +- You added an image file asset to the content project. +- You understood the Content Pipeline workflow and how MonoGame automates the process. +- You loaded and displayed your first asset using the [**ContentManager**](xref:Microsoft.Xna.Framework.Content.ContentManager). + +In the next chapter, we will explore working with textures in more detail and learning about different rendering options. + +## Test Your Knowledge + +1. What are the two main ways of loading a texture, and what are the pros and cons of each approach? + + :::question-answer + The two main ways to load a texture in MonoGame are: + + 1. Directly from file using [**Texture2D.FromFile**](xref:Microsoft.Xna.Framework.Graphics.Texture2D.FromFile(Microsoft.Xna.Framework.Graphics.GraphicsDevice,System.String)). This method requires manually setting up file copying, offers no pre-processing benefits, and can have a higher memory footprint. + + 2. Using the content pipeline with [**Content.Load\**](xref:Microsoft.Xna.Framework.Content.ContentManager.Load``1(System.String)). Using the content pipeline optimizes textures into formats for the target platform(s), automatically handles compiling and copying assets during build, and reduces memory footprint, but requires additional setup using the MGCB Editor. + ::: + +2. During the MonoGame content pipeline workflow, assets are compiled and then copied to the project output folder. What is responsible for performing this task? + + :::question-answer + The *MonoGame.Content.Builder.Tasks* NuGet reference. + ::: diff --git a/articles/tutorials/building_2d_games/05_content_pipeline/snippets/game1.cs b/articles/tutorials/building_2d_games/05_content_pipeline/snippets/game1.cs new file mode 100644 index 00000000..46ed5c3f --- /dev/null +++ b/articles/tutorials/building_2d_games/05_content_pipeline/snippets/game1.cs @@ -0,0 +1,55 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary; + +namespace DungeonSlime; + +public class Game1 : Core +{ + // The MonoGame logo texture + private Texture2D _logo; + + public Game1() : base("Dungeon Slime", 1280, 720, false) + { + + } + + protected override void Initialize() + { + // TODO: Add your initialization logic here + + base.Initialize(); + } + + protected override void LoadContent() + { + _logo = Content.Load("images/logo"); + } + + 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); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw the logo texture + SpriteBatch.Draw(_logo, Vector2.Zero, Color.White); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); + } +} diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/icon-on-top-of-wordmark.png b/articles/tutorials/building_2d_games/06_working_with_textures/images/icon-on-top-of-wordmark.png new file mode 100644 index 00000000..b96f0f67 Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/images/icon-on-top-of-wordmark.png differ diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/icon-wordmark-centered.png b/articles/tutorials/building_2d_games/06_working_with_textures/images/icon-wordmark-centered.png new file mode 100644 index 00000000..a7f6f397 Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/images/icon-wordmark-centered.png differ diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-centered.png b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-centered.png new file mode 100644 index 00000000..a69272ff Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-centered.png differ diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-drawn.png b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-drawn.png new file mode 100644 index 00000000..3e330c62 Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-drawn.png differ diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-flipped-horizontally-and-vertically.png b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-flipped-horizontally-and-vertically.png new file mode 100644 index 00000000..f7dcd41b Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-flipped-horizontally-and-vertically.png differ diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-flipped-horizontally.png b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-flipped-horizontally.png new file mode 100644 index 00000000..b5c74375 Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-flipped-horizontally.png differ diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-green-tint.png b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-green-tint.png new file mode 100644 index 00000000..2d0dbfa8 Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-green-tint.png differ diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-half-transparency.png b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-half-transparency.png new file mode 100644 index 00000000..efce0118 Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-half-transparency.png differ diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-off-center.png b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-off-center.png new file mode 100644 index 00000000..a88dd70c Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-off-center.png differ diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-rotated-centered.png b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-rotated-centered.png new file mode 100644 index 00000000..e93914b4 Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-rotated-centered.png differ diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-rotated-offcenter.png b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-rotated-offcenter.png new file mode 100644 index 00000000..f4e0c8c4 Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-rotated-offcenter.png differ diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-scaled-1.5x-0.5x.png b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-scaled-1.5x-0.5x.png new file mode 100644 index 00000000..f0a351ad Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-scaled-1.5x-0.5x.png differ diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-scaled-1.5x-zero-origin.png b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-scaled-1.5x-zero-origin.png new file mode 100644 index 00000000..f4a46bf0 Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-scaled-1.5x-zero-origin.png differ diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-scaled-1.5x.png b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-scaled-1.5x.png new file mode 100644 index 00000000..8bd7c01a Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-scaled-1.5x.png differ diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-texture-regions.drawio b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-texture-regions.drawio new file mode 100644 index 00000000..f34ad94b --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-texture-regions.drawio @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-texture-regions.png b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-texture-regions.png new file mode 100644 index 00000000..09eb566c Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/images/logo-texture-regions.png differ diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/index.md b/articles/tutorials/building_2d_games/06_working_with_textures/index.md new file mode 100644 index 00000000..ebf0d902 --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/index.md @@ -0,0 +1,353 @@ +--- +title: "Chapter 06: Working with Textures" +description: Learn how to load and render textures using the MonoGame content pipeline and SpriteBatch. +--- + +Textures are images that are used in your game to represent the visual graphics to the player, commonly referred to as *Sprites*. In [Chapter 05](../05_content_pipeline/index.md#loading-assets), you went through the steps of using the **Content Pipeline** to load the MonoGame *logo.png* texture and rendering it to the screen. + +In this chapter, you will: + +- Learn how to render a texture with the [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch). +- Explorer how to manipulate the way the texture is rendered using the parameters of the [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Draw(Microsoft.Xna.Framework.Graphics.Texture2D,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Color)) method. + +## Drawing a Texture + +When rendering in MonoGame, *render states*, properties of the [**GraphicsDevice**](xref:Microsoft.Xna.Framework.Graphics.GraphicsDevice) that affect how rendering is performed, need to be set. When rendering 2D sprites, the [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch) class simplifies rendering by managing these render states for you. + +> [!IMPORTANT] +> Although the [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch) makes it easier to manage the render states for the [**GraphicsDevice**](xref:Microsoft.Xna.Framework.Graphics.GraphicsDevice), it can also change states that you may have set manually, such as when you are performing 3D rendering. Keep this in mind when mixing 2D and 3D rendering. + +Three methods are are used when rendering with the [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch): + +1. [**SpriteBatch.Begin**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Begin(Microsoft.Xna.Framework.Graphics.SpriteSortMode,Microsoft.Xna.Framework.Graphics.BlendState,Microsoft.Xna.Framework.Graphics.SamplerState,Microsoft.Xna.Framework.Graphics.DepthStencilState,Microsoft.Xna.Framework.Graphics.RasterizerState,Microsoft.Xna.Framework.Graphics.Effect,System.Nullable{Microsoft.Xna.Framework.Matrix})) prepares the Graphics Device for rendering, including the render states. +2. [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Draw(Microsoft.Xna.Framework.Graphics.Texture2D,Microsoft.Xna.Framework.Rectangle,Microsoft.Xna.Framework.Color)) tells the [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch) what to render. This is usually called multiple times before [**SpriteBatch.End**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.End) and batches the draw calls for efficiency. +3. [**SpriteBatch.End**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.End) submits the draw calls that were batched to the graphics device to be rendered. + +> [!NOTE] +> The order of method calls when rendering using the [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch) is important. [**SpriteBatch.Begin**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Begin(Microsoft.Xna.Framework.Graphics.SpriteSortMode,Microsoft.Xna.Framework.Graphics.BlendState,Microsoft.Xna.Framework.Graphics.SamplerState,Microsoft.Xna.Framework.Graphics.DepthStencilState,Microsoft.Xna.Framework.Graphics.RasterizerState,Microsoft.Xna.Framework.Graphics.Effect,System.Nullable{Microsoft.Xna.Framework.Matrix})) must be called before any [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Draw(Microsoft.Xna.Framework.Graphics.Texture2D,Microsoft.Xna.Framework.Rectangle,Microsoft.Xna.Framework.Color)) calls are made. When finished, [**SpriteBatch.End**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.End) must be called before another [**SpriteBatch.Begin**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Begin(Microsoft.Xna.Framework.Graphics.SpriteSortMode,Microsoft.Xna.Framework.Graphics.BlendState,Microsoft.Xna.Framework.Graphics.SamplerState,Microsoft.Xna.Framework.Graphics.DepthStencilState,Microsoft.Xna.Framework.Graphics.RasterizerState,Microsoft.Xna.Framework.Graphics.Effect,System.Nullable{Microsoft.Xna.Framework.Matrix})) can be called. If these methods are called out of order, an exception will be thrown. + +As mentioned in [Chapter 03](../03_the_game1_file/index.md#the-game-loop), all rendering should be done inside the [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)) method. The [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)) method's responsibility is to render the game state that was calculated in [**Update**](xref:Microsoft.Xna.Framework.Game.Update(Microsoft.Xna.Framework.GameTime)); it should not contain any game logic or complex calculations. + +At the end of [Chapter 05](../05_content_pipeline/index.md#loading-assets), you added the following code to [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)) in the `Game1.cs` file: + +[!code-csharp[](./snippets/draw.cs?highlight=6-7,9-10,12-13)] + +These lines initialize the [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch), draw the logo at [**Vector2.Zero**](xref:Microsoft.Xna.Framework.Vector2.Zero) (0, 0), and complete the batch. When you ran the game and the logo appeared in the window's upper-left corner: + +| ![Figure 6-1: The MonoGame logo drawn to the game window](./images/logo-drawn.png) | +| :--------------------------------------------------------------------------------: | +| **Figure 6-1: The MonoGame logo drawn to the game window** | + +The [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Draw(Microsoft.Xna.Framework.Graphics.Texture2D,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Color)) method we just used can be given the following parameters: + +| Parameter | Type | Description | +| ---------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| *texture* | [**Texture2D**](xref:Microsoft.Xna.Framework.Graphics.Texture2D) | The [**Texture2D**](xref:Microsoft.Xna.Framework.Graphics.Texture2D) to draw. | +| *position* | [**Vector2**](xref:Microsoft.Xna.Framework.Vector2) | The X and Y coordinates at which the texture will be rendered, with the texture's origin being the upper-left corner of the image. | +| *color* | [**Color**](xref:Microsoft.Xna.Framework.Color) | The color mask (tint) to apply to the image drawn. Specifying [**Color.White**](xref:Microsoft.Xna.Framework.Color.White) will render the texture with no tint. | + +> [!TIP] +> Try adjusting the position and color parameters and see how they can affect the image being drawn. + +MonoGame uses a coordinate system where (0, 0) is at the screen's upper-left corner. X values increase moving right, and Y values increase moving down. Understanding this, we wil try to center the logo on the game window. + +To center content on the screen, we need to find the window's center point. We can access this using the [**Window.ClientBounds**](xref:Microsoft.Xna.Framework.GameWindow.ClientBounds) property from the [**Game**](xref:Microsoft.Xna.Framework.Game) class, which represents the rectangular bounds of the game window. [**Window.ClientBounds**](xref:Microsoft.Xna.Framework.GameWindow.ClientBounds) exposes both [**Width**](xref:Microsoft.Xna.Framework.Rectangle.Width) and [**Height**](xref:Microsoft.Xna.Framework.Rectangle.Height) properties for the window's dimensions in pixels. By dividing these dimensions in half, we can can calculate the window's center coordinates. We can update our [**Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Draw(Microsoft.Xna.Framework.Graphics.Texture2D,Microsoft.Xna.Framework.Rectangle,Microsoft.Xna.Framework.Color)) method to use this: + +[!code-csharp[](./snippets/draw_center_wrong.cs?highlight=9-16)] + +> [!TIP] +> In the example above, we multiply the [**Vector2**](xref:Microsoft.Xna.Framework.Vector2) created by `0.5f` to halve the value instead of dividing it by `2.0f`. If you are not used to seeing this, it might seem strange at first, but it is actually an optimization technique. CPUs are able to perform multiplication operations much faster than division operations and reading `* 0.5f` is easily understood to be the same thing as `/ 2.0f` when reading. + +We have now set the position to half the window's dimensions, which should center the logo. Run the game to see the result. + +| ![Figure 6-2: Attempting to draw the MonoGame logo centered on the game window](./images/logo-off-center.png) | +| :-----------------------------------------------------------------------------------------------------------: | +| **Figure 6-2: Attempting to draw the MonoGame logo centered on the game window** | + +The logo is not centered as we expected it to be. Even though we set the *position* parameter to the center of the game window, the texture starts drawing from its *origin*, which is the upper-left corner in this example. So when we set the position to the screen's center, we are actually placing the logo's upper-left corner at that point, not the center of the texture. + +One way to correct this is to subtract half the width and height of the texture from the game window's center position like so: + +[!code-csharp[](./snippets/draw_center.cs?highlight=12-14)] + +This offsets the position so that it correctly centers the image to the game window. + +| ![Figure 6-3: The MonoGame logo drawn centered on the game window](./images/logo-centered.png) | +| :--------------------------------------------------------------------------------------------: | +| **Figure 6-3: The MonoGame logo drawn centered on the game window** | + +While this works, there is a better approach. There is a different overload of the [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Draw(Microsoft.Xna.Framework.Graphics.Texture2D,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Color)) method that provides additional parameters for complete control over the draw operation, which we will use in the upcoming sections. + +Update your code to: + +[!code-csharp[](./snippets/draw_all_params.cs?highlight=10-22)] + +This overload produces the same centered result but exposes all parameters that control rendering for a draw operation. Unlike engines that abstract much of these details away, MonoGame provides explicit control for a flexible custom rendering pipeline. Here is what each parameter does: + +| Parameter | Type | Description | +| ----------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| *texture* | [**Texture2D**](xref:Microsoft.Xna.Framework.Graphics.Texture2D) | The [**Texture2D**](xref:Microsoft.Xna.Framework.Graphics.Texture2D) to draw. | +| *position* | [**Vector2**](xref:Microsoft.Xna.Framework.Vector2) | The X and Y coordinate position at which the texture will be rendered, relative to the *origin* parameter. | +| *sourceRectangle* | [**Rectangle**](xref:Microsoft.Xna.Framework.Rectangle) | An optional region within the texture to be rendered in order to draw only a portion of the texture. Specifying `null` will render the entire texture. | +| *color* | [**Color**](xref:Microsoft.Xna.Framework.Color) | The color mask (tint) to apply to the image drawn. Specifying [**Color.White**](xref:Microsoft.Xna.Framework.Color.White) will render the texture with no tint. | +| *rotation* | `float` | The amount of rotation, in radians, to apply to the texture when rendering. Specifying `0.0f` will render the image with no rotation. | +| *origin* | [**Vector2**](xref:Microsoft.Xna.Framework.Vector2) | The X and Y coordinate origin point of the texture when rendering. This will affect the offset of the texture when rendered as well being the origin in which the texture is rotated around and scaled from. | +| *scale* | `float` | The amount to scale the image across the x- and y-axes. Specifying `1.0f` will render the image at its default size with no scaling. | +| *effects* | [**SpriteEffects**](xref:Microsoft.Xna.Framework.Graphics.SpriteEffects) | A [**SpriteEffects**](xref:Microsoft.Xna.Framework.Graphics.SpriteEffects) enum value to that specifies if the texture should be rendered flipped across the horizontal axis, the vertical axis, or both axes. | +| *layerDepth* | `float` | Specifies the depth at which the texture is rendered. Textures with a higher layer depth value are drawn on top of those with a lower layer depth value. **Note: This value will only apply when using `SpriteSortMode.FrontToBack` or `SpriteSortMode.BackToFront`. We will cover this in a moment.** | + +### Rotation + +First we will explore the `rotation` parameter. This value is the amount of rotation to apply to the sprite when rendering it. We will rotate the texture 90° to make it vertical. Since rotation is measured in radians, not degrees, we can use the built-in math library in MonoGame to make the conversion for us by calling [**MathHelper.ToRadians**](xref:Microsoft.Xna.Framework.MathHelper.ToRadians(System.Single)). Update the code to: + +[!code-csharp[](./snippets/rotation.cs?highlight=17)] + +Running the code now shows the rotated image, but not in the expected position: + +| ![Figure 6-4: Attempting to draw the MonoGame logo rotated 90° and centered on the game window](./images/logo-rotated-offcenter.png) | +| :----------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 6-4: Attempting to draw the MonoGame logo rotated 90° and centered on the game window** | + +The reason the sprite did not rotate as expected is because of the `origin` parameter. + +### Origin + +The `origin` parameter specifies the point of origin in which the sprite is rendered from, rotated from, and scaled from. By default, if no origin is set, it will be [**Vector2.Zero**](xref:Microsoft.Xna.Framework.Vector2.Zero), the upper-left corner of the sprite. To visualize this, see *Figure 6-5* below. The red square represents where the origin is for the sprite, and we can see how it is rotated around this origin point. + +| ![Figure 6-5: Demonstration of how a sprite is rotated around its origin](./videos/top-left-origin-rotation-example.webm) | +| :-----------------------------------------------------------------------------------------------------------------------: | +| **Figure 6-5: Demonstration of how a sprite is rotated around its origin** | + +To resolve the rotation issue we had, we need to need to change two things: + +1. Set the `origin` parameter to the center of the sprite instead of defaulting to the upper-left corner. +2. Change the `position` parameter back to the center of the screen. + +Update the code to: + +[!code-csharp[](./snippets/origin.cs?highlight=12-14,18-20)] + +By moving the sprite's origin point to its center, this not only corrects the point of rotation, but also allows us to use the screen center position directly without needing additional position offset calculations. Running the game now shows the log properly centered and rotated 90°. + +> [!NOTE] +> When setting the `origin` parameter, it is based on the sprites width and height, so the center origin will be half the width and half the height of the sprite. + +| ![Figure 6-6: The MonoGame logo drawn rotated 90° and centered on the game window](./images/logo-rotated-centered.png) | +| :--------------------------------------------------------------------------------------------------------------------: | +| **Figure 6-6: The MonoGame logo drawn rotated 90° and centered on the game window** | + +### Scale + +The `scale` parameter specifies the amount of scaling to apply to the sprite when it is rendered. The default value is `1.0f`, which can be read as "rendering the sprite at 1x the size". Increasing this will scale up the size of the sprite and decreasing it will scale down the sprite. + +For this example, we will first reset the rotation back to `0.0f` (removing the 90° rotation we applied above) so we can clearly see the scaling effect. Then we will set the scale of the logo sprite to `1.5f` + +[!code-csharp[](./snippets/scale.cs?highlight=17,21)] + +| ![Figure 6-7: The MonoGame logo drawn scaled at 1.5x the size](./images/logo-scaled-1.5x.png) | +| :-------------------------------------------------------------------------------------------: | +| **Figure 6-7: The MonoGame logo drawn scaled at 1.5x the size** | + +Note that the sprite scaled up from the center. This is because we still have the `origin` parameter set as the center of the sprite. If we instead adjusted the code so the `origin` parameter was back in the upper-left corner like so: + +[!code-csharp[](./snippets/scale_no_origin.cs?highlight=18-19)] + +Then the scaling is applied from the origin in the upper-left corner producing the following result: + +| ![Figure 6-8: The MonoGame logo drawn scaled at 1.5x the size with the origin set in the upper-left corner](./images/logo-scaled-1.5x-zero-origin.png) | +| :----------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 6-8: The MonoGame logo drawn scaled at 1.5x the size with the origin set in the upper-left corner** | + +Scaling can also be applied to the x- and y-axes independently by providing it with a [**Vector2**](xref:Microsoft.Xna.Framework.Vector2) value instead of a float value. For instance, we can scale the x-axis of the sprite by 1.5x and reduce the scale of the y-axis to 0.5x: + +[!code-csharp[](./snippets/scale_vector2.cs?highlight=21)] + +Which will produce the following result: + +| ![Figure 6-9: The MonoGame logo drawn scaled at 1.5x the size on the x-axis and 0.5x on the y-axis](./images/logo-scaled-1.5x-0.5x.png) | +| :-------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 6-9: The MonoGame logo drawn scaled at 1.5x the size on the x-axis and 0.5x on the y-axis** | + +### SpriteEffects + +The `effects` parameter is used to flip the sprite when rendered on either the horizontal or vertical axis, or both. This value for this parameter will be one of the [**SpriteEffects**](xref:Microsoft.Xna.Framework.Graphics.SpriteEffects) enum values. + +| SpriteEffect | Description | +| ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| [**SpriteEffects.None**](xref:Microsoft.Xna.Framework.Graphics.SpriteEffects.None) | No effect is applied and the sprite is rendered normally. | +| [**SpriteEffects.FlipHorizontally**](xref:Microsoft.Xna.Framework.Graphics.SpriteEffects.FlipHorizontally) | The sprite is rendered flipped along the horizontal axis. | +| [**SpriteEffects.FlipVertically**](xref:Microsoft.Xna.Framework.Graphics.SpriteEffects.FlipVertically) | The sprite is rendered flipped along the vertical axis. | + +For this example, we will reset the scale back to `1.0f` and apply the [**SpriteEffects.FlipHorizontally**](xref:Microsoft.Xna.Framework.Graphics.SpriteEffects.FlipHorizontally) value to the sprite: + +[!code-csharp[](./snippets/spriteeffects.cs?highlight=21,22)] + +Which will produce the following result: + +| ![Figure 6-10: The MonoGame logo flipped horizontally](./images/logo-flipped-horizontally.png) | +| :--------------------------------------------------------------------------------------------: | +| **Figure 6-10: The MonoGame logo flipped horizontally** | + +The [**SpriteEffects**](xref:Microsoft.Xna.Framework.Graphics.SpriteEffects) enum value also uses the [`[Flag]`](https://learn.microsoft.com/en-us/dotnet/fundamentals/runtime-libraries/system-flagsattribute) attribute, which means we can combine both horizontal and vertical flipping together. To do this, we use the [bitwise OR operator](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/bitwise-and-shift-operators#logical-or-operator-) `|`. Update the `effect` parameter value to the following: + +[!code-csharp[](./snippets/spriteeffects_flags.cs?highlight=22-23)] + +Now the sprite is flipped both horizontally and vertically + +| ![Figure 6-11: The MonoGame logo flipped horizontally and vertically](./images/logo-flipped-horizontally-and-vertically.png) | +| :--------------------------------------------------------------------------------------------------------------------------: | +| **Figure 6-11: The MonoGame logo flipped horizontally and vertically** | + +### Color and Opacity + +The `color` parameter applies a color mask to the sprite when it is rendered. Note that this is not setting the actual color of the image, just a mask that is applied, like a tint. The default value is [**Color.White**](xref:Microsoft.Xna.Framework.Color.White). So if we are setting it to [**Color.White**](xref:Microsoft.Xna.Framework.Color.White), why does this not affect the tinting of the sprite drawn? + +When the `color` parameter is applied, each color channel (Red, Green, Blue) of the sprite is multiplied by the corresponding channel in the `color` parameter, where each channel is represented as a value between `0.0f` and `1.0f`. For [**Color.White**](xref:Microsoft.Xna.Framework.Color.White), all color channels are set to `1.0f` (255 in byte form), so the multiplication looks like this: + +```sh +Final Red = Sprite Red * 1.0f +Final Green = Sprite Green * 1.0f +Final Blue = Sprite Blue * 1.0f; +``` + +Since multiplying by `1.0f` does not change the value, [**Color.White**](xref:Microsoft.Xna.Framework.Color.White) essentially preserves the original colors of the sprite. + +For this example, we will reset the `effects` parameter back to [**SpriteEffects.None**](xref:Microsoft.Xna.Framework.Graphics.SpriteEffects.None) and update the `color` parameter to use [**Color.Green**](xref:Microsoft.Xna.Framework.Color.Green): + +[!code-csharp[](./snippets/color.cs?highlight=16,22)] + +This produces the following result: + +| ![Figure 6-12: The MonoGame logo with a green color tint applied](./images/logo-green-tint.png) | +| :---------------------------------------------------------------------------------------------: | +| **Figure 6-12: The MonoGame logo with a green color tint applied** | + +> [!NOTE] +> The icon and the word "GAME" in the logo look black after using a [**Color.Green**](xref:Microsoft.Xna.Framework.Color.Green) because the Red, Blue Green components of that color are (`0.0f`, `0.5f`, `0.0f`). The Orange color used in the logo is [**Color.MonoGameOrange**](xref:Microsoft.Xna.Framework.Color.MonoGameOrange), which has the component values of (`0.9f`, `0.23f`, `0.0f`). When multiplying the component values, the result is (`0.0f`, `0.125f`, `0.0f`) which would be Red 0, Green 31, Blue 0 in byte values. So it is not quite fully black, but it is very close. +> +> This is why it is important to understand how the `color` parameter values are applied to the sprite when it is rendered. + +To adjust the opacity of a sprite, we can multiply the `color` parameter value by a value between `0.0f` (fully transparent) and `1.0f` (fully opaque). For instance, if we wanted to render the logo with 50% transparency we can multiply the `color` parameter by `0.5f` like this: + +[!code-csharp[](./snippets/opacity.cs?highlight=16)] + +Which will produce the following result: + +| ![Figure 6-13: The MonoGame logo with half transparency](./images/logo-half-transparency.png) | +| :-------------------------------------------------------------------------------------------: | +| **Figure 6-13: The MonoGame logo with half transparency** | + +### Source Rectangle + +The `sourceRectangle` parameter specifies a specific boundary within the texture that should be rendered. So far, we have just set this parameter to `null`, which specifies that the full texture should be rendered. If we only wanted to render a portion of the texture as the sprite, we can set this parameter value. + +For instance, take the logo image we have been using. We can break it down into two distinct regions; the MonoGame icon and the MonoGame wordmark. + +| ![Figure 6-14: The MonoGame logo broken down into the icon and wordmark regions](./images/logo-texture-regions.png) | +| :-----------------------------------------------------------------------------------------------------------------: | +| **Figure 6-14: The MonoGame logo broken down into the icon and wordmark regions** | + +We can see from *Figure 6-14* above that the actual icon starts at position (0, 0) and is 128px wide and 128px tall. Likewise, the wordmark starts at position (150, 34) and is 458px wide and 58px tall. Knowing the starting position and the width and height of the region gives us a defined rectangle that we can use as the `sourceRectangle`. + +We can see this in action by drawing the icon and the wordmark separately from the same texture. Update the code to the following: + +[!code-csharp[](./snippets/sourcerectangle.cs?highlight=6-7,9-10,15-30,32-47)] + +The following changes were made: + +- Two new [**Rectangle**](xref:Microsoft.Xna.Framework.Rectangle) values called `iconSourceRect` and `wordmarkSourceRect` that represent the boundaries of the MonoGame icon and wordmark regions within the logo texture were added. +- The *sourceRectangle* parameter of the `_spriteBatch.Draw` was updated to use the new `iconSourceRect` value. **Notice that we are still telling it to draw the `_logo` for the *texture*, we have just supplied it with a source rectangle this time.** +- The *origin* parameter was updated to use the width and height of the `iconSourceRect`. Since the overall dimensions of what we will be rendering has changed due to supplying a source rectangle, the origin needs to be adjusted to those dimensions as well. +- Finally, a second `_spriteBatch.Draw` call is made, this time using the `wordmarkSourceRect` as the source rectangle so that the wordmark is drawn. + +If you run the game now, you should see the following: + +| ![Figure 6-15: The MonoGame icon and wordmark, from the logo texture, centered in the game window](./images/icon-wordmark-centered.png) | +| :-------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 6-15: The MonoGame icon and wordmark, from the logo texture, centered in the game window** | + +> [!NOTE] +> Making use of the `sourceRectangle` parameter to draw different sprites from the same texture is optimization technique that we will explore further in the next chapter. + +### Layer Depth + +The final parameter to discuss is the `layerDepth` parameter. Notice that in *Figure 6-15* above, the word mark is rendered on top of the icon. This is because of the order the draw calls were made; first the icon was rendered, then the word mark was rendered. + +The [**SpriteBatch.Begin**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Begin(Microsoft.Xna.Framework.Graphics.SpriteSortMode,Microsoft.Xna.Framework.Graphics.BlendState,Microsoft.Xna.Framework.Graphics.SamplerState,Microsoft.Xna.Framework.Graphics.DepthStencilState,Microsoft.Xna.Framework.Graphics.RasterizerState,Microsoft.Xna.Framework.Graphics.Effect,System.Nullable{Microsoft.Xna.Framework.Matrix})) method contains several optional parameters, one of which is the `sortMode` parameter. By default, this value is [**SpriteSortMode.Deferred**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode.Deferred), which means what is drawn is done so in the order of the [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.DrawString(Microsoft.Xna.Framework.Graphics.SpriteFont,System.Text.StringBuilder,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Color,System.Single,Microsoft.Xna.Framework.Vector2,System.Single,Microsoft.Xna.Framework.Graphics.SpriteEffects,System.Single)) calls. Each subsequent call will be drawn visually on top of the previous call. + +When [**SpriteSortMode.Deferred**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode.Deferred) is used, then the `layerDepth` parameter in the [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.DrawString(Microsoft.Xna.Framework.Graphics.SpriteFont,System.Text.StringBuilder,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Color,System.Single,Microsoft.Xna.Framework.Vector2,System.Single,Microsoft.Xna.Framework.Graphics.SpriteEffects,System.Single)) call is essentially ignored. For instance, in the first `_spriteBatch.Draw` method call, update the `layerDepth` parameter to `1.0f`. + +[!code-csharp[](./snippets/layerdepth.cs?highlight=29)] + +Doing this should tell it to render on a layer above the wordmark since the icon is at `1.0f` and the wordmark is at `0.0f` for the `layerDepth`. However, if you run the game now, you will see that no change actually happens; the wordmark is still drawn on top of the icon. + +To make use of the `layerDepth` parameter, you need to set the `sortMode` to either [**SpriteSortMode.BackToFront**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode.BackToFront) or [**SpriteSortMode.FrontToBack**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode.FrontToBack). + +| Sort Mode | Description | +| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| [**SpriteSortMode.BackToFront**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode.BackToFront) | Sprites are sorted by depth in back-to-front order prior to drawing. | +| [**SpriteSortMode.FrontToBack**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode.FrontToBack) | Sprites are sorted by depth in front-to-back order prior to drawing. | + +Now we can see this in action. We have already set the `layerDepth` parameter of the icon to `1.0f`. Find the `_spriteBatch.Begin()` method call and update it to the following: + +[!code-csharp[](./snippets/sortmode.cs?highlight=13)] + +Now we are telling it to use the [**SpriteSortMode.FrontToBack**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode.FrontToBack) sort mode, which will sort the draw calls so that those with a higher `layerDepth` will be drawn on top of those with a lower one. Even though we did not change the order of the `_spriteBatch.Draw` calls, if you run the game now, you will see the following: + +| ![Figure 6-16: The MonoGame icon drawn on top of the wordmark](./images/icon-on-top-of-wordmark.png) | +| :--------------------------------------------------------------------------------------------------: | +| **Figure 6-16: The MonoGame icon drawn on top of the wordmark** | + +There are also two additional [**SpriteSortMode**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode) values that can be used. These, however, are situational and can have draw backs when using them, so understanding what they are for is important. + +The first is [**SpriteSortMode.Texture**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode.Texture). This works similar to [**SpriteSortMode.Deferred**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode.Deferred) in that draw calls happen in the order they are made. However, before the draw calls are made, they are sorted by texture. This can be helpful when using multiple textures to reduce texture swapping, however it can have unintended results with layering if you are not careful. + +The second is [**SpriteSortMode.Immediate**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode.Immediate). When using this sort mode, when a draw call is made, it is immediately flushed to the GPU and rendered to the screen, ignoring the layer depth, instead of batched and drawn when [**SpriteBatch.End**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.End) is called. Using this can cause performance issues and should only be used when necessary. We will discuss an example of using this in a later chapter when we discuss shaders, since with [**SpriteSortMode.Immediate**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode.Immediate) you can adjust shader parameters for each individual draw call. + +## Conclusion + +In this chapter, you accomplished the following: + +- You learned about the different parameters of the [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Draw(Microsoft.Xna.Framework.Graphics.Texture2D,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Color)) method and how they affect sprite rendering. +- You learned how the `rotation` parameter works and how to convert between degrees and radians using [**MathHelper.ToRadians**](xref:Microsoft.Xna.Framework.MathHelper.ToRadians(System.Single)). +- You learned how the `origin` parameter affects sprite positioning, rotation, and scaling. +- You learned how to use the `scale` parameter to resize sprites uniformly or along individual axes. +- You explored the [**SpriteEffects**](xref:Microsoft.Xna.Framework.Graphics.SpriteEffects) enum to flip sprites horizontally and vertically. +- You learned how the `color` parameter can be used to tint sprites and adjust their opacity. +- You used the `sourceRectangle` parameter to draw specific regions from a texture. +- You explored sprite layering using the `layerDepth` parameter and different [**SpriteSortMode**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode) options. + +In the next chapter, we will take what we have learned about working with textures and learn techniques to optimize rendering to reduce texture swapping. + +## Test Your Knowledge + +1. What is the purpose of the `origin` parameter in SpriteBatch.Draw, and how does it affect position, rotation and scaling? + + :::question-answer + The `origin` parameter determines the reference point for the sprite's position, rotation, and scaling. When set to [**Vector2.Zero**](xref:Microsoft.Xna.Framework.Vector2.Zero), the sprite rotates and scales from its upper-left corner. When set to the center of the sprite, the sprite rotates and scales from its center. The origin point also affects where the sprite is positioned relative to the `position` parameter. + ::: + +2. How can you adjust a sprite's opacity using [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.DrawString(Microsoft.Xna.Framework.Graphics.SpriteFont,System.Text.StringBuilder,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Color,System.Single,Microsoft.Xna.Framework.Vector2,System.Single,Microsoft.Xna.Framework.Graphics.SpriteEffects,System.Single))? + + :::question-answer + A sprite's opacity can be adjusted by multiplying the `color` parameter by a value between `0.0f` (fully transparent) and `1.0f` (fully opaque). For example, `Color.White * 0.5f` will render the sprite at 50% opacity. + ::: + +3. How can you flip a sprite horizontally and vertically at the same time using SpriteEffects? + + :::question-answer + To flip a sprite both horizontally and vertically, you can combine the SpriteEffects values using the bitwise OR operator (`|`): + + ```cs + SpriteEffects.FlipHorizontally | SpriteEffects.FlipVertically + ``` + + ::: + +4. When using the `sourceRectangle` parameter, what information do you need to specify, and what is its purpose? + + :::question-answer + The `sourceRectangle` parameter requires a [**Rectangle**](xref:Microsoft.Xna.Framework.Rectangle) value where the x- and y-coordinates specify the upper-left corner of the region within the texture and the width and height, in pixels, of the region. + + Its purpose is to specify a specific region within a texture to draw, allowing multiple sprites to be drawn from different parts of the same texture. + ::: diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/color.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/color.cs new file mode 100644 index 00000000..7ba80984 --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/color.cs @@ -0,0 +1,30 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw the texture + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + Window.ClientBounds.Width, + Window.ClientBounds.Height) * 0.5f, + null, // sourceRectangle + Color.Green, // color + 0.0f, // rotation + new Vector2( // origin + _logo.Width, + _logo.Height) * 0.5f, + 1.0f, // scale + SpriteEffects.None, // effects + 0.0f + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/draw.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/draw.cs new file mode 100644 index 00000000..1cb3b5c8 --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/draw.cs @@ -0,0 +1,16 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw the texture + SpriteBatch.Draw(_logo, Vector2.Zero, Color.White); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/draw_all_params.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/draw_all_params.cs new file mode 100644 index 00000000..5bb7d74b --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/draw_all_params.cs @@ -0,0 +1,28 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw the texture + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + (Window.ClientBounds.Width * 0.5f) - (_logo.Width * 0.5f), + (Window.ClientBounds.Height * 0.5f) - (_logo.Height * 0.5f)), + null, // sourceRectangle + Color.White, // color + 0.0f, // rotation + Vector2.Zero, // origin + 1.0f, // scale + SpriteEffects.None, // effects + 0.0f // layerDepth + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/draw_center.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/draw_center.cs new file mode 100644 index 00000000..447d7795 --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/draw_center.cs @@ -0,0 +1,22 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw the logo texture + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + (Window.ClientBounds.Width * 0.5f) - (_logo.Width * 0.5f), + (Window.ClientBounds.Height * 0.5f) - (_logo.Height * 0.5f)), + Color.White // color + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/draw_center_wrong.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/draw_center_wrong.cs new file mode 100644 index 00000000..553702f6 --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/draw_center_wrong.cs @@ -0,0 +1,23 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw the logo texture + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + Window.ClientBounds.Width, + Window.ClientBounds.Height) + * 0.5f, + Color.White // color + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/layerdepth.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/layerdepth.cs new file mode 100644 index 00000000..79e57f76 --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/layerdepth.cs @@ -0,0 +1,53 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // The bounds of the icon within the texture. + Rectangle iconSourceRect = new Rectangle(0, 0, 128, 128); + + // The bounds of the word mark within the texture. + Rectangle wordmarkSourceRect = new Rectangle(150, 34, 458, 58); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw only the icon portion of the texture. + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + Window.ClientBounds.Width, + Window.ClientBounds.Height) * 0.5f, + iconSourceRect, // sourceRectangle + Color.White, // color + 0.0f, // rotation + new Vector2( // origin + iconSourceRect.Width, + iconSourceRect.Height) * 0.5f, + 1.0f, // scale + SpriteEffects.None, // effects + 1.0f // layerDepth + ); + + // Draw only the word mark portion of the texture. + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + Window.ClientBounds.Width, + Window.ClientBounds.Height) * 0.5f, + wordmarkSourceRect, // sourceRectangle + Color.White, // color + 0.0f, // rotation + new Vector2( // origin + wordmarkSourceRect.Width, + wordmarkSourceRect.Height) * 0.5f, + 1.0f, // scale + SpriteEffects.None, // effects + 0.0f // layerDepth + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/opacity.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/opacity.cs new file mode 100644 index 00000000..f204d296 --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/opacity.cs @@ -0,0 +1,30 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw the texture + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + Window.ClientBounds.Width, + Window.ClientBounds.Height) * 0.5f, + null, // sourceRectangle + Color.White * 0.5f, // color + 0.0f, // rotation + new Vector2( // origin + _logo.Width, + _logo.Height) * 0.5f, + 1.0f, // scale + SpriteEffects.None, // effects + 0.0f + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/origin.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/origin.cs new file mode 100644 index 00000000..646e2039 --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/origin.cs @@ -0,0 +1,30 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw the texture + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + Window.ClientBounds.Width, + Window.ClientBounds.Height) * 0.5f, + null, // sourceRectangle + Color.White, // color + MathHelper.ToRadians(90), // rotation + new Vector2( // origin + _logo.Width, + _logo.Height) * 0.5f, + 1.0f, // scale + SpriteEffects.None, // effects + 0.0f // layerDepth + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/rotation.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/rotation.cs new file mode 100644 index 00000000..af26ea6f --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/rotation.cs @@ -0,0 +1,28 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw the texture + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + (Window.ClientBounds.Width * 0.5f) - (_logo.Width * 0.5f), + (Window.ClientBounds.Height * 0.5f) - (_logo.Height * 0.5f)), + null, // sourceRectangle + Color.White, // color + MathHelper.ToRadians(90), // rotation + Vector2.Zero, // origin + 1.0f, // scale + SpriteEffects.None, // effects + 0.0f // layerDepth + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/scale.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/scale.cs new file mode 100644 index 00000000..790cc1ca --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/scale.cs @@ -0,0 +1,30 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw the texture + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + Window.ClientBounds.Width, + Window.ClientBounds.Height) * 0.5f, + null, // sourceRectangle + Color.White, // color + 0.0f, // rotation + new Vector2( // origin + _logo.Width, + _logo.Height) * 0.5f, + 1.5f, // scale + SpriteEffects.None, // effects + 0.0f // layerDepth + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/scale_no_origin.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/scale_no_origin.cs new file mode 100644 index 00000000..3f0b7c9e --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/scale_no_origin.cs @@ -0,0 +1,28 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw the texture + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + Window.ClientBounds.Width, + Window.ClientBounds.Height) * 0.5f, + null, // sourceRectangle + Color.White, // color + 0.0f, // rotation + Vector2.Zero, // origin + 1.5f, // scale + SpriteEffects.None, // effects + 0.0f //layerDepth + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/scale_vector2.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/scale_vector2.cs new file mode 100644 index 00000000..d20c0d7c --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/scale_vector2.cs @@ -0,0 +1,30 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw the texture + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + Window.ClientBounds.Width, + Window.ClientBounds.Height) * 0.5f, + null, // sourceRectangle + Color.White, // color + 0.0f, // rotation + new Vector2( // origin + _logo.Width, + _logo.Height) * 0.5f, + new Vector2(1.5f, 0.5f), // scale + SpriteEffects.None, // effects + 0.0f // layerDepth + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/sortmode.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/sortmode.cs new file mode 100644 index 00000000..37518074 --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/sortmode.cs @@ -0,0 +1,53 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // The bounds of the icon within the texture. + Rectangle iconSourceRect = new Rectangle(0, 0, 128, 128); + + // The bounds of the word mark within the texture. + Rectangle wordmarkSourceRect = new Rectangle(150, 34, 458, 58); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(sortMode: SpriteSortMode.FrontToBack); + + // Draw only the icon portion of the texture. + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + Window.ClientBounds.Width, + Window.ClientBounds.Height) * 0.5f, + iconSourceRect, // sourceRectangle + Color.White, // color + 0.0f, // rotation + new Vector2( // origin + iconSourceRect.Width, + iconSourceRect.Height) * 0.5f, + 1.0f, // scale + SpriteEffects.None, // effects + 1.0f // layerDepth + ); + + // Draw only the word mark portion of the texture. + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + Window.ClientBounds.Width, + Window.ClientBounds.Height) * 0.5f, + wordmarkSourceRect, // sourceRectangle + Color.White, // color + 0.0f, // rotation + new Vector2( // origin + wordmarkSourceRect.Width, + wordmarkSourceRect.Height) * 0.5f, + 1.0f, // scale + SpriteEffects.None, // effects + 0.0f // layerDepth + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/sourcerectangle.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/sourcerectangle.cs new file mode 100644 index 00000000..8e2b2aae --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/sourcerectangle.cs @@ -0,0 +1,53 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // The bounds of the icon within the texture. + Rectangle iconSourceRect = new Rectangle(0, 0, 128, 128); + + // The bounds of the word mark within the texture. + Rectangle wordmarkSourceRect = new Rectangle(150, 34, 458, 58); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw only the icon portion of the texture. + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + Window.ClientBounds.Width, + Window.ClientBounds.Height) * 0.5f, + iconSourceRect, // sourceRectangle + Color.White, // color + 0.0f, // rotation + new Vector2( // origin + iconSourceRect.Width, + iconSourceRect.Height) * 0.5f, + 1.0f, // scale + SpriteEffects.None, // effects + 0.0f // layerDepth + ); + + // Draw only the word mark portion of the texture. + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + Window.ClientBounds.Width, + Window.ClientBounds.Height) * 0.5f, + wordmarkSourceRect, // sourceRectangle + Color.White, // color + 0.0f, // rotation + new Vector2( // origin + wordmarkSourceRect.Width, + wordmarkSourceRect.Height) * 0.5f, + 1.0f, // scale + SpriteEffects.None, // effects + 0.0f // layerDepth + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/spriteeffects.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/spriteeffects.cs new file mode 100644 index 00000000..234cd235 --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/spriteeffects.cs @@ -0,0 +1,30 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw the texture + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + Window.ClientBounds.Width, + Window.ClientBounds.Height) * 0.5f, + null, // sourceRectangle + Color.White, // color + 0.0f, // rotation + new Vector2( // origin + _logo.Width, + _logo.Height) * 0.5f, + 1.0f, // scale + SpriteEffects.FlipHorizontally, // effects + 0.0f // layerDepth + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/snippets/spriteeffects_flags.cs b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/spriteeffects_flags.cs new file mode 100644 index 00000000..8f2d4f43 --- /dev/null +++ b/articles/tutorials/building_2d_games/06_working_with_textures/snippets/spriteeffects_flags.cs @@ -0,0 +1,31 @@ +protected override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(); + + // Draw the texture + SpriteBatch.Draw( + _logo, // texture + new Vector2( // position + Window.ClientBounds.Width, + Window.ClientBounds.Height) * 0.5f, + null, // sourceRectangle + Color.White, // color + 0.0f, // rotation + new Vector2( // origin + _logo.Width, + _logo.Height) * 0.5f, + 1.0f, // scale + SpriteEffects.FlipHorizontally | // effects + SpriteEffects.FlipVertically, + 0.0f // layerDepth + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/06_working_with_textures/videos/top-left-origin-rotation-example.webm b/articles/tutorials/building_2d_games/06_working_with_textures/videos/top-left-origin-rotation-example.webm new file mode 100644 index 00000000..48849ae5 Binary files /dev/null and b/articles/tutorials/building_2d_games/06_working_with_textures/videos/top-left-origin-rotation-example.webm differ diff --git a/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/images/atlas.png b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/images/atlas.png new file mode 100644 index 00000000..7238c847 Binary files /dev/null and b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/images/atlas.png differ diff --git a/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/images/logo-texture-regions.png b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/images/logo-texture-regions.png new file mode 100644 index 00000000..09eb566c Binary files /dev/null and b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/images/logo-texture-regions.png differ diff --git a/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/images/mgcb-editor-copy.png b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/images/mgcb-editor-copy.png new file mode 100644 index 00000000..54c41e75 Binary files /dev/null and b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/images/mgcb-editor-copy.png differ diff --git a/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/images/pong-atlas.png b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/images/pong-atlas.png new file mode 100644 index 00000000..7ca80388 Binary files /dev/null and b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/images/pong-atlas.png differ diff --git a/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/images/slime-and-bat-rendered.png b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/images/slime-and-bat-rendered.png new file mode 100644 index 00000000..e30dcbae Binary files /dev/null and b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/images/slime-and-bat-rendered.png differ diff --git a/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/index.md b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/index.md new file mode 100644 index 00000000..6d575134 --- /dev/null +++ b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/index.md @@ -0,0 +1,279 @@ +--- +title: "Chapter 07: Optimizing Texture Rendering" +description: Explore optimization techniques when rendering textures using a texture atlas. +--- + +In [Chapter 06](../06_working_with_textures/index.md), you learned how to load and render textures using [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch). While rendering individual textures works well for simple games, it can lead to performance issues as your game grows more complex. In this chapter, we will explore how to optimize texture rendering by reducing texture swaps and creating reusable components for better organization. + +In this chapter, you will: + +- Learn about texture swapping and its impact on performance. +- Explore texture atlases as a solution for optimizing texture rendering. +- Create reusable classes to optimize and simplify texture management and rendering. + +By the end of this chapter, you will understand how to organize your game's textures for optimal performance and have a flexible texture atlas management system for your future game projects. + +## Texture Swapping + +Every time the [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Draw(Microsoft.Xna.Framework.Graphics.Texture2D,Microsoft.Xna.Framework.Vector2,System.Nullable{Microsoft.Xna.Framework.Rectangle},Microsoft.Xna.Framework.Color,System.Single,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Graphics.SpriteEffects,System.Single)) method is executed with a different *texture* parameter than the previous [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Draw(Microsoft.Xna.Framework.Graphics.Texture2D,Microsoft.Xna.Framework.Vector2,System.Nullable{Microsoft.Xna.Framework.Rectangle},Microsoft.Xna.Framework.Color,System.Single,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Graphics.SpriteEffects,System.Single)) method call, a *texture swap* occurs, unbinding the current texture on the GPU and binding the new texture. + +> [!NOTE] +> A texture swap occurs when the GPU needs to switch between different textures during rendering. While each individual swap may seem trivial, the cumulative effect in a complex game can significantly impact performance. + +For example, here is are the simplified draw call for an example Pong game: + +[!code-csharp[](./snippets/pong_example.cs)] + +In the above example: + +1. The paddle texture is bound to the GPU so the left player paddle can be drawn. +2. The paddle texture is unbound from the GPU and the ball texture is bound so that the ball can be drawn (Texture Swap #1). +3. The ball texture is unbound from the GPU and the paddle texture is bound again so the right player paddle can be drawn (Texture Swap #2). + +These texture swaps, while negligible in this example, can become a performance issue in a full game where you might be drawing hundreds or thousands of sprites per frame. + +### Attempting to Optimize Draw Order + +One approach to get around this could be to optimize the order of the draw calls to minimize texture swaps For example, if we reorder the draw calls from the previous example so that both paddles are drawn first and then the ball, the number of texture swaps is reduced from two to one: + +[!code-csharp[](./snippets/draw_order.cs)] + +However this is not a scalable solution. In a real game with dozens of different textures and complex draw orders for layered sprites, UI elements, particles, etc., managing draw order by texture becomes impractical and will conflict with desired visual layering. + +## What is a Texture Atlas + +A texture atlas (also known as a sprite sheet) is a large image file that contains multiple smaller images packed together. Instead of loading separate textures for each sprite, you load the single texture file with all the images combined like a scrapbook where all your photos are arranged on the same page. + +> [!NOTE] +> Using a texture atlas not only eliminates texture swaps but also reduces memory usage and simplifies asset management since you are loading and tracking a single texture instead of many individual ones. + +In the Pong example, imagine taking the paddle and ball image and combining them into a single image file like in *Figure 7-1* below: + +| ![Figure 7-1: Pong Texture Atlas Example](./images/pong-atlas.png) | +|:------------------------------------------------------------------:| +| **Figure 7-1: Pong Texture Atlas Example** | + +Now when we draw these images, we would be using the same texture and just specify the source rectangles for the paddle or ball when needed, completely eliminating texture swaps. + +[!code-csharp[](./snippets/pong_texture_atlas_example.cs)] + +While using the single texture with source rectangles solves the potential performance issues, managing multiple source rectangles in variables can become complex as your game grows. In the Pong example above, we are already tracking the source rectangles for both the paddle and ball sprites. Imagine scaling this up to a game with dozens of different images, each potentially needing their own position, rotation, scale, and other rendering properties. + +To better organize this complexity, we can apply object-oriented design principles to create classes that encapsulates the information needed. + +## The TextureRegion Class + +In [Chapter 06](../06_working_with_textures/index.md#source-rectangle), we learned about using the `sourceRectangle` parameter to reuse the same texture when rendering sprites but specifying different regions within the texture to render. For our next step, we will build on this and create a class called `TextureRegion`. + +We are going to add this class to the class library we created in [Chapter 04](../04_creating_a_class_library/index.md). Perform the following: + +1. Add new folder in the *MonoGameLibrary* project named `Graphics` +2. Create a new file named `TextureRegion.cs` inside the *Graphics* folder you just created. +3. Add the following code for the foundation of the `TextureRegion` class to the file: + +[!code-csharp[](./snippets/textureregion.cs#declaration)] + +> [!NOTE] +> The `TextureRegion.cs` class file is placed in the *MonoGame/Graphics* folder and the class uses the `MonoGameLibrary.Graphics` [namespace](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/types/namespaces#namespaces-overview) to keep graphics-related classes organized together. As we add more functionality to the library, we will continue to use directories and namespaces to maintain a clean structure. + +We will add several components to this class in sequence. Each section below should be added to the `TextureRegion` class in the order presented between the brackets ` { } ` of the class definition. As we go through each part, the class will gradually take shape to handle all the texture handling behavior we need. + +### TextureRegion Members + +The `TextureRegion` class will utilize four properties to define and manage a region within a texture. Add the following properties: + +[!code-csharp[](./snippets/textureregion.cs#properties)] + +The `Texture` and `SourceRectangle` properties work together to define where the region is located: `Texture` specifies which texture contains the region, while `SourceRectangle` defines its exact location and size within that texture. The `Width` and `Height` properties provide convenient access to the region's dimensions without having to access the SourceRectangle property directly. + +### TextureRegion Constructor + +The `TextureRegion` class provides two ways to create a new texture region. + +Add the following constructors: + +[!code-csharp[](./snippets/textureregion.cs#ctors)] + +* The default constructor creates an empty texture region that can be configured later. +* The parameterized constructor allows you to define the region's source texture and boundary in a single step. + +The second constructor provides a convenient way to create texture regions when you know the exact location and dimensions within the source texture upfront. + +### TextureRegion Methods + +Finally, the `TextureRegion` class will provide three overloaded Draw methods to render the texture region. Add the following methods: + +[!code-csharp[](./snippets/textureregion.cs#methods)] + +These methods provide flexible options for rendering the texture region, similar to what the [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Draw(Microsoft.Xna.Framework.Graphics.Texture2D,Microsoft.Xna.Framework.Vector2,System.Nullable{Microsoft.Xna.Framework.Rectangle},Microsoft.Xna.Framework.Color,System.Single,Microsoft.Xna.Framework.Vector2,System.Single,Microsoft.Xna.Framework.Graphics.SpriteEffects,System.Single)) method does: + +- The simplest overload requires only position and color. +- A second overload exposes all rendering parameters while allowing for a single float value to be applied to both axes for scaling. +- The third overload is the most flexible, offering all rendering parameters and independent x- and y-axis scaling. + +Only the last `Draw` method actually uses the `Texture` specified in the class, as the other two methods both depend on this final implementation, simplifying the code needed to actually do the drawing. + +## The TextureAtlas Class + +In the [What is a Texture Atlas](#what-is-a-texture-atlas) section above, a texture atlas was described as a scrap book that holds all of the individual sprites for the game. These individual sprites can now be represented by the `TextureRegion` class we just created. Now, we will create the `TextureAtlas` class to represent the collection of the regions that make up all of our sprites. + +Just like the `TextureRegion` class, we are going to add this to the class library. In the *Graphics* folder within the *MonoGameLibrary* project, add a new file named `TextureAtlas.cs`, then add the following code for the foundation of the `TextureAtlas` class: + +[!code-csharp[](./snippets/textureatlas.cs#declaration)] + +As before, each section below adds more functionality to this class, one after the other within the `TextureAtlas` class. + +### TextureAtlas Members + +The `TextureAtlas` class needs two key members to manage texture regions. Add the following: + +[!code-csharp[](./snippets/textureatlas.cs#members)] + +The private `_regions` dictionary stores named texture regions, allowing us to retrieve specific regions by name, while the `Texture` property holds the source texture that contains all the regions. Together, these members enable the atlas to manage multiple texture regions from a single source texture. + +### TextureAtlas Constructors + +The `TextureAtlas` class provides two ways to create a new atlas. + +Add the following constructors: + +[!code-csharp[](./snippets/textureatlas.cs#ctors)] + +* The default constructor creates an empty atlas that can be configured later. +* The parameterized constructor allows you to specify the source texture immediately. + +Both constructors initialize the `_regions` dictionary so that it is ready to be used either way. + +### TextureAtlas Methods + +Finally, The `TextureAtlas` class will provide methods for managing texture regions and creating atlases from configuration files. Add the following methods: + +[!code-csharp[](./snippets/textureatlas.cs#methods)] + +These methods serve different purposes in managing the texture atlas: + +1. Region Management + - `AddRegion`: Creates a new `TextureRegion` at the specified location in the atlas. + - `GetRegion`: Retrieves a previously added region by its name. + - `RemoveRegion`: Removes a specific region by its name. + - `Clear`: Removes all regions from the atlas. +2. Atlas Creation + - `FromFile`: creates a new `TextureAtlas` from an XML configuration file. This method will load the source texture then create and add the regions based on the XML configuration. We will look more into using the XML configuration in a moment. + +## Using the TextureAtlas Class + +No we can put our new `TextureAtlas` class to use by exploring two approaches; creating an atlas manually and using XML configuration. So far, we have been practicing using textures with the MonoGame logo. Now we will use a new texture atlas that contains various sprites we will need for our game. + +Download the texture atlas by right-clicking the following image and saving it as atlas.png: + +| ![Figure 7-2: The texture atlas for our game](./images/atlas.png) | +|:-----------------------------------------------------------------:| +| **Figure 7-2: The texture atlas for our game** | + +> [!TIP] +> You may notice that our texture atlas image has some empty areas, which seems like a waste. Its dimensions are 256x256 pixels when it could have just been 240x160 pixels. +> +> **This is intentional.** +> +> Game graphics often use texture dimensions that are powers of 2 (128, 256, 512, 1024, etc.) for technical reasons. While modern graphics hardware can handle any texture size, power-of-2 dimensions provide better memory efficiency and more precise rendering. When pixel coordinates are converted to texture coordinates during rendering, powers of 2 can be represented more precisely in floating-point calculations, helping prevent visual artifacts like texture seams. +> +> This is not critical for simple 2D games, but adopting this practice early will serve you well as your games become more complex. + +Add this texture atlas to your content project using the MGCB Editor: + +1. Open the *Content.mgcb* file in the MGCB Editor +2. In the editor, right-click the *images* folder and choose *Add > Existing item...*. +3. Navigate to and choose the *atlas.png* file you downloaded to add it. +4. Save the changes and close the MGCB Editor. + +> [!TIP] +> If you need a refresher on adding content using the MGCB Editor, you can revisit the [Chapter 05: The Content Pipeline](../05_content_pipeline/index.md). + +First, we will explore creating the texture atlas and defining the texture regions directly in code. Replace the contents of `Game1.cs` with the following: + +[!code-csharp[](./snippets/game1/textureatlas_usage.cs?highlight=5,11-15,31-47,65-75)] + +The key changes in this implementation are: + +1. The `_logo` field was removed. +2. Added `TextureRegion` members for the slime and bat sprites. +3. In [**LoadContent**](xref:Microsoft.Xna.Framework.Game.LoadContent): + - Removed loading the logo texture. + - Created a `TextureAtlas` with the atlas texture. + - Added regions for both the slime and the bat. + - Retrieved the regions using their names. +4. Updated [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)) to: + - Draw the slime at a scale factor of 4. + - Draw the bat 10 pixels to the right of the bat based on the slime's `Width` property, at a scale of 4 + +Running the game now shows both sprites in the upper-left corner: + +| ![Figure 7-3: The slime and bat texture regions being rendered in the upper-left corner of the game window](./images/slime-and-bat-rendered.png) | +|:------------------------------------------------------------------------------------------------------------------------------------------------:| +| **Figure 7-3: The slime and bat texture regions being rendered in the upper-left corner of the game window** | + +While manual creation works for a few sprites, managing many regions becomes cumbersome. Now we will explore the `TextureAtlas.FromFile` method to load our atlas configuration from XML instead. Perform the following: + +1. Open the *Content.mgcb* file in the MGCB Editor +1. Create a new file named `atlas-definition` in the *Content/images* folder using "Add -> New Item -> XML Content". +1. In the properties panel at the bottom for the `atlas-definition.xml` file, change the *`Build Action`* property from `Build` to *`Copy`*. +1. Save the changes and close the MGCB Editor +1. Open the new `atlas-definition.xml` file in your code editor (The MGCB editor cannot edit files, only manage the what content it builds) +1. Replace the contents of the XML file with the following: + + [!code-xml[](./snippets/atlas_definition.xml)] + + + | ![Figure 7-4: The atlas-definition.xml file added to the content project with the Build Action property set to Copy](./images/mgcb-editor-copy.png) | + |:---------------------------------------------------------------------------------------------------------------------------------------------------:| + | **Figure 7-4: The atlas-definition.xml file added to the content project with the Build Action property set to Copy** | + + + > [!TIP] + > Using the content pipeline to copy files ensures they are placed in the correct location alongside other game content. While there are other methods (like editing the .csproj), this approach keeps asset management centralized + +1. Update the contents of `Game1.cs` with the following code: + + [!code-csharp[](./snippets/game1/textureatlas_xml_usage.cs?highlight=31-32)] + +The key improvements here is in [**LoadContent**](xref:Microsoft.Xna.Framework.Game.LoadContent), where we now: + +- Create an atlas from the XML configuration file. +- Let the `TextureAtlas.FromFile` method handle texture loading and region creation. +- Removed the manual creation of regions in code. + +This configuration based approached is advantageous because we can now add new and modify existing regions within the atlas without having to change code and/or recompile. This also keeps the sprite definitions separate from the game logic. + +Running the game now will show the same results as *Figure 7-4* above, with the slime and bat texture regions rendered in the upper-left corner of the game window. + +## Conclusion + +In this chapter, you accomplished the following: + +- Learned about texture swapping and its impact on performance +- Explored texture atlases as a solution for optimizing texture rendering +- Learned what a class library is and the benefits of using one. +- Created reusable `TextureRegion` and `TextureAtlas` classes to optimize and simplify texture management. +- Learned how to include assets in the content pipeline that should only be copied and not processed. + +In the next chapter, we will build on the concepts of the `TextureAtlas` and explore creating the `Sprite` and `AnimatedSprite` classes to further simplify managing and rendering sprites. + +## Test Your Knowledge + +1. What is a texture swap and why can it impact performance? + + :::question-answer + A texture swap occurs when the GPU needs to unbind one texture and bind another between draw calls. While individual swaps may seem trivial, they can significantly impact performance in games with many sprites as each swap is an expensive GPU operation. + ::: + +2. Name a benefit of using a texture atlas. + + :::question-answer + Any of the following are benefits of using a texture atlas: + + - Eliminates texture swaps by using a single texture + - Reduces memory usage + - Simplifies asset management + - Improves rendering performance + + ::: diff --git a/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/atlas_definition.xml b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/atlas_definition.xml new file mode 100644 index 00000000..b5e8c3f8 --- /dev/null +++ b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/atlas_definition.xml @@ -0,0 +1,8 @@ + + + images/atlas + + + + + \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/draw_order.cs b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/draw_order.cs new file mode 100644 index 00000000..7d48c3dd --- /dev/null +++ b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/draw_order.cs @@ -0,0 +1,5 @@ +// Render the left and right paddles first. +// This reduces the number of texture swaps needed from two to one. +_spriteBatch.Draw(paddleTexture, _leftPaddlePosition, Color.White); +_spriteBatch.Draw(paddleTexture, _rightPaddlePosition, Color.White); +_spriteBatch.Draw(ballTexture, _ballPosition, Color.White); \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/game1/textureatlas_usage.cs b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/game1/textureatlas_usage.cs new file mode 100644 index 00000000..b461b107 --- /dev/null +++ b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/game1/textureatlas_usage.cs @@ -0,0 +1,79 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; + +namespace DungeonSlime; + +public class Game1 : Core +{ + // texture region that defines the slime sprite in the atlas. + private TextureRegion _slime; + + // texture region that defines the bat sprite in the atlas. + private TextureRegion _bat; + + public Game1() : base("Dungeon Slime", 1280, 720, false) + { + + } + + protected override void Initialize() + { + // TODO: Add your initialization logic here + + base.Initialize(); + } + + protected override void LoadContent() + { + // Load the atlas texture using the content manager + Texture2D atlasTexture = Content.Load("images/atlas"); + + // Create a TextureAtlas instance from the atlas + TextureAtlas atlas = new TextureAtlas(atlasTexture); + + // add the slime region to the atlas. + atlas.AddRegion("slime", 0, 0, 20, 20); + + // add the bat region to the atlas. + atlas.AddRegion("bat", 20, 0, 20, 20); + + // retrieve the slime region from the atlas. + _slime = atlas.GetRegion("slime"); + + // retrieve the bat region from the atlas. + _bat = atlas.GetRegion("bat"); + } + + 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) + { + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + // Draw the slime texture region at a scale of 4.0 + _slime.Draw(SpriteBatch, Vector2.Zero, Color.White, 0.0f, Vector2.One, 4.0f, SpriteEffects.None, 0.0f); + + // Draw the bat texture region 10px to the right of the slime at a scale of 4.0 + _bat.Draw(SpriteBatch, new Vector2(_slime.Width * 4.0f + 10, 0), Color.White, 0.0f, Vector2.One, 4.0f, SpriteEffects.None, 1.0f); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/game1/textureatlas_xml_usage.cs b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/game1/textureatlas_xml_usage.cs new file mode 100644 index 00000000..6b3850ce --- /dev/null +++ b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/game1/textureatlas_xml_usage.cs @@ -0,0 +1,70 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; + +namespace DungeonSlime; + +public class Game1 : Core +{ + // texture region that defines the slime sprite in the atlas. + private TextureRegion _slime; + + // texture region that defines the bat sprite in the atlas. + private TextureRegion _bat; + + public Game1() : base("Dungeon Slime", 1280, 720, false) + { + + } + + protected override void Initialize() + { + // TODO: Add your initialization logic here + + base.Initialize(); + } + + protected override void LoadContent() + { + // Create the texture atlas from the XML configuration file + TextureAtlas atlas = TextureAtlas.FromFile(Content, "images/atlas-definition.xml"); + + // retrieve the slime region from the atlas. + _slime = atlas.GetRegion("slime"); + + // retrieve the bat region from the atlas. + _bat = atlas.GetRegion("bat"); + } + + 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) + { + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + // Draw the slime texture region at a scale of 4.0 + _slime.Draw(SpriteBatch, Vector2.Zero, Color.White, 0.0f, Vector2.One, 4.0f, SpriteEffects.None, 0.0f); + + // Draw the bat texture region 10px to the right of the slime at a scale of 4.0 + _bat.Draw(SpriteBatch, new Vector2(_slime.Width * 4.0f + 10, 0), Color.White, 0.0f, Vector2.One, 4.0f, SpriteEffects.None, 1.0f); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/pong_example.cs b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/pong_example.cs new file mode 100644 index 00000000..728506f0 --- /dev/null +++ b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/pong_example.cs @@ -0,0 +1,11 @@ +// Using the paddle texture to render the left player paddle. +// The paddle texture is bound to the GPU. +_spriteBatch.Draw(paddleTexture, leftPaddlePosition, Color.White); + +// Using the ball texture to render the ball +// A texture swap occurs, unbinding the paddle texture to bind the ball texture. +_spriteBatch.Draw(ballTexture, ballPosition, Color.White); + +// Reusing the paddle texture to draw the right player paddle. +// A texture swap occurs again, unbinding the ball texture to bind the paddle texture. +_spriteBatch.Draw(paddleTexture, rightPaddlePosition, Color.White); \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/pong_texture_atlas_example.cs b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/pong_texture_atlas_example.cs new file mode 100644 index 00000000..242e8d30 --- /dev/null +++ b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/pong_texture_atlas_example.cs @@ -0,0 +1,24 @@ +private Texture2D _textureAtlas; +private Rectangle _paddleSourceRect; +private Rectangle _ballSourceRect; + +protected override void LoadContent() +{ + _textureAtlas = Content.Load("pong-atlas"); + _paddleSourceRect = new Rectangle(0, 0, 32, 32); + _ballSourceRect = new Rectangle(32, 0, 32, 32); +} + +protected override void Draw(GameTime gameTime) +{ + GraphicsDevice.Clear(Color.CornflowerBlue); + + _spriteBatch.Begin(); + + // All draw calls use the same texture, so there is no texture swapping! + _spriteBatch.Draw(_textureAtlas, _leftPaddlePosition, _paddleSourceRect, Color.White); + _spriteBatch.Draw(_textureAtlas, _rightPaddlePosition, _paddleSourceRect, Color.White); + _spriteBatch.Draw(_textureAtlas, _ballPosition, _ballSourceRect, Color.White); + + _spriteBatch.End(); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/textureatlas.cs b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/textureatlas.cs new file mode 100644 index 00000000..61d1c6e3 --- /dev/null +++ b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/textureatlas.cs @@ -0,0 +1,149 @@ +#region declaration +using System.Collections.Generic; +using System.IO; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; + +namespace MonoGameLibrary.Graphics; + +public class TextureAtlas +{ + +} +#endregion +{ + #region members + private Dictionary _regions; + + /// + /// Gets or Sets the source texture represented by this texture atlas. + /// + public Texture2D Texture { get; set; } + #endregion + + #region ctors + /// + /// Creates a new texture atlas. + /// + public TextureAtlas() + { + _regions = new Dictionary(); + } + + /// + /// Creates a new texture atlas instance using the given texture. + /// + /// The source texture represented by the texture atlas. + public TextureAtlas(Texture2D texture) + { + Texture = texture; + _regions = new Dictionary(); + } + #endregion + + #region methods + /// + /// Creates a new region and adds it to this texture atlas. + /// + /// The name to give the texture region. + /// The top-left x-coordinate position of the region boundary relative to the top-left corner of the source texture boundary. + /// The top-left y-coordinate position of the region boundary relative to the top-left corner of the source texture boundary. + /// The width, in pixels, of the region. + /// The height, in pixels, of the region. + public void AddRegion(string name, int x, int y, int width, int height) + { + TextureRegion region = new TextureRegion(Texture, x, y, width, height); + _regions.Add(name, region); + } + + /// + /// Gets the region from this texture atlas with the specified name. + /// + /// The name of the region to retrieve. + /// The TextureRegion with the specified name. + public TextureRegion GetRegion(string name) + { + return _regions[name]; + } + + /// + /// Removes the region from this texture atlas with the specified name. + /// + /// The name of the region to remove. + /// + public bool RemoveRegion(string name) + { + return _regions.Remove(name); + } + + /// + /// Removes all regions from this texture atlas. + /// + public void Clear() + { + _regions.Clear(); + } + + /// + /// Creates a new texture atlas based a texture atlas xml configuration file. + /// + /// The content manager used to load the texture for the atlas. + /// The path to the xml file, relative to the content root directory. + /// The texture atlas created by this method. + public static TextureAtlas FromFile(ContentManager content, string fileName) + { + TextureAtlas atlas = new TextureAtlas(); + + string filePath = Path.Combine(content.RootDirectory, fileName); + + using (Stream stream = TitleContainer.OpenStream(filePath)) + { + using (XmlReader reader = XmlReader.Create(stream)) + { + XDocument doc = XDocument.Load(reader); + XElement root = doc.Root; + + // The element contains the content path for the Texture2D to load. + // So we will retrieve that value then use the content manager to load the texture. + string texturePath = root.Element("Texture").Value; + atlas.Texture = content.Load(texturePath); + + // The element contains individual elements, each one describing + // a different texture region within the atlas. + // + // Example: + // + // + // + // + // + // So we retrieve all of the elements then loop through each one + // and generate a new TextureRegion instance from it and add it to this atlas. + var regions = root.Element("Regions")?.Elements("Region"); + + if (regions != null) + { + foreach (var region in regions) + { + string name = region.Attribute("name")?.Value; + int x = int.Parse(region.Attribute("x")?.Value ?? "0"); + int y = int.Parse(region.Attribute("y")?.Value ?? "0"); + int width = int.Parse(region.Attribute("width")?.Value ?? "0"); + int height = int.Parse(region.Attribute("height")?.Value ?? "0"); + + if (!string.IsNullOrEmpty(name)) + { + atlas.AddRegion(name, x, y, width, height); + } + } + } + + return atlas; + } + } + } + #endregion +} diff --git a/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/textureregion.cs b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/textureregion.cs new file mode 100644 index 00000000..407bb946 --- /dev/null +++ b/articles/tutorials/building_2d_games/07_optimizing_texture_rendering/snippets/textureregion.cs @@ -0,0 +1,122 @@ +#region declaration +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace MonoGameLibrary.Graphics; + +/// +/// Represents a rectangular region within a texture. +/// +public class TextureRegion +{ + +} +#endregion +{ + #region properties + /// + /// Gets or Sets the source texture this texture region is part of. + /// + public Texture2D Texture { get; set; } + + /// + /// Gets or Sets the source rectangle boundary of this texture region within the source texture. + /// + public Rectangle SourceRectangle { get; set; } + + /// + /// Gets the width, in pixels, of this texture region. + /// + public int Width => SourceRectangle.Width; + + /// + /// Gets the height, in pixels, of this texture region. + /// + public int Height => SourceRectangle.Height; + #endregion + + #region ctors + /// + /// Creates a new texture region. + /// + public TextureRegion() { } + + /// + /// Creates a new texture region using the specified source texture. + /// + /// The texture to use as the source texture for this texture region. + /// The x-coordinate position of the upper-left corner of this texture region relative to the upper-left corner of the source texture. + /// + /// The width, in pixels, of this texture region. + /// The height, in pixels, of this texture region. + public TextureRegion(Texture2D texture, int x, int y, int width, int height) + { + Texture = texture; + SourceRectangle = new Rectangle(x, y, width, height); + } + #endregion + + #region methods + /// + /// Submit this texture region for drawing in the current batch. + /// + /// The spritebatch instance used for batching draw calls. + /// The xy-coordinate location to draw this texture region on the screen. + /// The color mask to apply when drawing this texture region on screen. + public void Draw(SpriteBatch spriteBatch, Vector2 position, Color color) + { + Draw(spriteBatch, position, color, 0.0f, Vector2.Zero, Vector2.One, SpriteEffects.None, 0.0f); + } + + /// + /// Submit this texture region for drawing in the current batch. + /// + /// The spritebatch instance used for batching draw calls. + /// The xy-coordinate location to draw this texture region on the screen. + /// The color mask to apply when drawing this texture region on screen. + /// The amount of rotation, in radians, to apply when drawing this texture region on screen. + /// The center of rotation, scaling, and position when drawing this texture region on screen. + /// The scale factor to apply when drawing this texture region on screen. + /// Specifies if this texture region should be flipped horizontally, vertically, or both when drawing on screen. + /// The depth of the layer to use when drawing this texture region on screen. + public void Draw(SpriteBatch spriteBatch, Vector2 position, Color color, float rotation, Vector2 origin, float scale, SpriteEffects effects, float layerDepth) + { + Draw( + spriteBatch, + position, + color, + rotation, + origin, + new Vector2(scale, scale), + effects, + layerDepth + ); + } + + /// + /// Submit this texture region for drawing in the current batch. + /// + /// The spritebatch instance used for batching draw calls. + /// The xy-coordinate location to draw this texture region on the screen. + /// The color mask to apply when drawing this texture region on screen. + /// The amount of rotation, in radians, to apply when drawing this texture region on screen. + /// The center of rotation, scaling, and position when drawing this texture region on screen. + /// The amount of scaling to apply to the x- and y-axes when drawing this texture region on screen. + /// Specifies if this texture region should be flipped horizontally, vertically, or both when drawing on screen. + /// The depth of the layer to use when drawing this texture region on screen. + public void Draw(SpriteBatch spriteBatch, Vector2 position, Color color, float rotation, Vector2 origin, Vector2 scale, SpriteEffects effects, float layerDepth) + { + spriteBatch.Draw( + Texture, + position, + SourceRectangle, + color, + rotation, + origin, + scale, + effects, + layerDepth + ); + } + #endregion +} diff --git a/articles/tutorials/building_2d_games/08_the_sprite_class/images/slime-and-bat-rendered.png b/articles/tutorials/building_2d_games/08_the_sprite_class/images/slime-and-bat-rendered.png new file mode 100644 index 00000000..e30dcbae Binary files /dev/null and b/articles/tutorials/building_2d_games/08_the_sprite_class/images/slime-and-bat-rendered.png differ diff --git a/articles/tutorials/building_2d_games/08_the_sprite_class/index.md b/articles/tutorials/building_2d_games/08_the_sprite_class/index.md new file mode 100644 index 00000000..272ed59e --- /dev/null +++ b/articles/tutorials/building_2d_games/08_the_sprite_class/index.md @@ -0,0 +1,129 @@ +--- +title: "Chapter 08: The Sprite Class" +description: "Explore creating a reusable Sprite class to efficiently sprites and their rendering properties, including position, rotation, scale, and more." +--- + +In [Chapter 07](../07_optimizing_texture_rendering/index.md), you learned how to use texture atlases to optimize rendering performance. While this solved the issue of texture swapping, managing individual sprites and their properties becomes increasingly complex as your game grows. Even in our simple example with just a slime and a bat, we would eventually need to track various properties for each sprite: + +- Color mask for tinting. +- Origin for rotation and scale. +- Scale for size adjustments. +- Rotation for orientation. +- Sprite effects to flip horizontally and/or vertically. +- Layer depth for draw order layering. + +Imagine scaling this up to dozens of sprites, each with multiple instances on screen. Tracking all these properties through individual variables quickly becomes unmanageable. In this chapter, we will solve this by creating a class that encapsulates sprite information and handles rendering. + +## The Sprite Class + +A sprite in our game represents a visual object created from a texture region, along with its rendering properties. While multiple sprites might use the same texture region (like multiple enemies of the same type), each sprite can have unique properties that control how it appears on screen; its position, rotation, scale, and other visual characteristics. + +By creating a `Sprite` class, we can encapsulate both the texture region and its rendering parameters into a single, reusable component. This not only makes our code more organized but also makes it easier to manage multiple instances of the same type of sprite. + +In the *Graphics* folder within the *MonoGameLibrary* project, add a new file named `Sprite.cs` and add the following code for the foundation of the `Sprite` class to the file: + +[!code-csharp[](./snippets/sprite.cs#declaration)] + +### Properties + +The `Sprite` class will utilize properties that mirror the parameters used in [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Draw(Microsoft.Xna.Framework.Graphics.Texture2D,Microsoft.Xna.Framework.Vector2,System.Nullable{Microsoft.Xna.Framework.Rectangle},Microsoft.Xna.Framework.Color,System.Single,Microsoft.Xna.Framework.Vector2,System.Single,Microsoft.Xna.Framework.Graphics.SpriteEffects,System.Single)) so the rendering parameter for each sprite is self contained. Add the following properties: + +[!code-csharp[](./snippets/sprite.cs#members)] + +The `TextureRegion` property works to provide the texture and source rectangle when rendering the sprite. Other properties directly correspond to [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Draw(Microsoft.Xna.Framework.Graphics.Texture2D,Microsoft.Xna.Framework.Vector2,System.Nullable{Microsoft.Xna.Framework.Rectangle},Microsoft.Xna.Framework.Color,System.Single,Microsoft.Xna.Framework.Vector2,System.Single,Microsoft.Xna.Framework.Graphics.SpriteEffects,System.Single)) parameters with the same default values, making it easy to understand how each property affects the sprite's appearance. + +> [!TIP] +> The calculated `Width` and `Height` properties make it easier to position sprites relative to each other without manually applying scale factors. + +### Constructors + +The `Sprite` class provides two ways to create a new sprite. + +Add the following constructors: + +[!code-csharp[](./snippets/sprite.cs#ctors)] + +* The default constructor creates an empty sprite that can be configured later. +* The parameterized constructor allows you to specify the source texture region for the sprite. + +### Methods + +Finally, add the following two method to the `Sprite` class: + +[!code-csharp[](./snippets/sprite.cs#methods)] + +- `CenterOrigin`: Sets the origin point of the sprite to its center. + + > [!NOTE] + > The origin needs to be set based on the width and height of the source texture region itself, regardless of the scale the sprite is rendered at. + +- `Draw`: Uses the `TextureRegion` property to submit the sprite for rendering using the properties of the sprite itself. + +## Create Sprites With The TextureAtlas Class + +While the `GetRegion` method of the `TextureAtlas` class we created in [Chapter 07](../07_optimizing_texture_rendering/index.md#the-textureatlas-class) works well for retrieving regions, creating sprites requires multiple steps: + +1. Get the region by name. +2. Store it in a variable. +3. Create a new sprite with that region. + +We can simplify this process by adding a sprite creation method to the `TextureAtlas` class. Open the `TextureAtlas.cs` and add the following method: + +[!code-csharp[](./snippets/createsprite.cs)] + +## Using the Sprite Class + +Now we can adjust our game now to use the `Sprite` class instead of just the texture regions. Update the contents of `Game1.cs` with the following: + +[!code-csharp[](./snippets/game1.cs?highlight=11-15,34-40,61-65)] + +The key changes in this implementation are: + +- The `_slime` and `_bat` members were changed from `TextureRegion` to `Sprite`. +- In [**LoadContent**](xref:Microsoft.Xna.Framework.Game.LoadContent) + - The `_slime` and `_bat` sprites are now created using the new `TextureAtlas.CreateSprite` method. + - Both the `_slime` and `_bat` sprites are given a scale of 4.0f. +- In [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)), the draw calls were updated to use the `Sprite.Draw` method. + +Running the game now will produce the same result as in the previous chapter. + +| ![Figure 8-1: The slime and bat sprites being rendered in the upper-left corner of the game window](./images/slime-and-bat-rendered.png) | +|:----------------------------------------------------------------------------------------------------------------------------------------:| +| **Figure 8-1: The slime and bat sprites being rendered in the upper-left corner of the game window** | + +> [!NOTE] +> Notice how even though we increased the scale of both sprites, the bat sprite is still only 10px to the right of the bat. This is because the `Width` property we created for the `Sprite` class takes into account the scale factor of the sprite as well. + +Try adjusting the various properties available for the slime and the bat sprites to see how they affect the rendering. + +## Conclusion + +In this chapter, we created a reusable `Sprite` class that encapsulates the properties for each sprite that we would render. The `TextureAtlas` class was updated to simplify sprite creation based on the `Sprite` class we created. + +In the next chapter, we will build upon the `Sprite` class to create an `AnimatedSprite` class that will allow us to bring our sprites to life through animation. + +## Test Your Knowledge + +1. What is the benefit of using a Sprite class instead of managing texture regions directly? + + :::question-answer + The `Sprite` class encapsulates all rendering properties (position, rotation, scale, etc.) into a single, reusable component. This makes it easier to manage multiple instances of the same type of sprite without having to track properties through individual variables. + ::: + +2. Why do the `Width` and `Height` properties of a Sprite take the Scale property into account? + + :::question-answer + The `Width` and `Height` properties account for scaling to make it easier to position sprites relative to each other without having to manually calculate the scaled dimensions. This is particularly useful when sprites are rendered at different scales. + ::: + +3. When using the `CenterOrigin` method, why is the origin calculated using the region's dimensions rather than the sprite's scaled dimensions? + + :::question-answer + The origin needs to be set based on the texture region's actual dimensions because it represents the point around which scaling and rotation are applied. Using the scaled dimensions would result in incorrect positioning since the origin would change based on the current scale factor. + ::: + +4. What advantage does the `TextureAtlas.CreateSprite` method provide over using `GetRegion`? + + :::question-answer + The `CreateSprite` method simplifies sprite creation by combining multiple steps (getting the region, storing it, creating a sprite) into a single method call. This reduces code repetition and makes sprite creation more straightforward. + ::: diff --git a/articles/tutorials/building_2d_games/08_the_sprite_class/snippets/createsprite.cs b/articles/tutorials/building_2d_games/08_the_sprite_class/snippets/createsprite.cs new file mode 100644 index 00000000..0933af68 --- /dev/null +++ b/articles/tutorials/building_2d_games/08_the_sprite_class/snippets/createsprite.cs @@ -0,0 +1,10 @@ +/// +/// Creates a new sprite using the region from this texture atlas with the specified name. +/// +/// The name of the region to create the sprite with. +/// A new Sprite using the texture region with the specified name. +public Sprite CreateSprite(string regionName) +{ + TextureRegion region = GetRegion(regionName); + return new Sprite(region); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/08_the_sprite_class/snippets/game1.cs b/articles/tutorials/building_2d_games/08_the_sprite_class/snippets/game1.cs new file mode 100644 index 00000000..b2dff791 --- /dev/null +++ b/articles/tutorials/building_2d_games/08_the_sprite_class/snippets/game1.cs @@ -0,0 +1,72 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; + +namespace DungeonSlime; + +public class Game1 : Core +{ + // Defines the slime sprite. + private Sprite _slime; + + // Defines the bat sprite. + private Sprite _bat; + + public Game1() : base("Dungeon Slime", 1280, 720, false) + { + + } + + protected override void Initialize() + { + // TODO: Add your initialization logic here + + base.Initialize(); + } + + protected override void LoadContent() + { + // Create the texture atlas from the XML configuration file + TextureAtlas atlas = TextureAtlas.FromFile(Content, "images/atlas-definition.xml"); + + // Create the slime sprite from the atlas. + _slime = atlas.CreateSprite("slime"); + _slime.Scale = new Vector2(4.0f, 4.0f); + + // Create the bat sprite from the atlas. + _bat = atlas.CreateSprite("bat"); + _bat.Scale = new Vector2(4.0f, 4.0f); + } + + 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) + { + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + // Draw the slime sprite. + _slime.Draw(SpriteBatch, Vector2.One); + + // Draw the bat sprite 10px to the right of the slime. + _bat.Draw(SpriteBatch, new Vector2(_slime.Width + 10, 0)); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/08_the_sprite_class/snippets/sprite.cs b/articles/tutorials/building_2d_games/08_the_sprite_class/snippets/sprite.cs new file mode 100644 index 00000000..13e8bd04 --- /dev/null +++ b/articles/tutorials/building_2d_games/08_the_sprite_class/snippets/sprite.cs @@ -0,0 +1,119 @@ +#region declaration +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace MonoGameLibrary.Graphics; + +public class Sprite +{ + +} +#endregion +{ + #region members + /// + /// Gets or Sets the source texture region represented by this sprite. + /// + public TextureRegion Region { get; set; } + + /// + /// Gets or Sets the color mask to apply when rendering this sprite. + /// + /// + /// Default value is Color.White + /// + public Color Color { get; set; } = Color.White; + + /// + /// Gets or Sets the amount of rotation, in radians, to apply when rendering this sprite. + /// + /// + /// Default value is 0.0f + /// + public float Rotation { get; set; } = 0.0f; + + /// + /// Gets or Sets the scale factor to apply to the x- and y-axes when rendering this sprite. + /// + /// + /// Default value is Vector2.One + /// + public Vector2 Scale { get; set; } = Vector2.One; + + /// + /// Gets or Sets the xy-coordinate origin point, relative to the top-left corner, of this sprite. + /// + /// + /// Default value is Vector2.Zero + /// + public Vector2 Origin { get; set; } = Vector2.Zero; + + /// + /// Gets or Sets the sprite effects to apply when rendering this sprite. + /// + /// + /// Default value is SpriteEffects.None + /// + public SpriteEffects Effects { get; set; } = SpriteEffects.None; + + /// + /// Gets or Sets the layer depth to apply when rendering this sprite. + /// + /// + /// Default value is 0.0f + /// + public float LayerDepth { get; set; } = 0.0f; + + /// + /// Gets the width, in pixels, of this sprite. + /// + /// + /// Width is calculated by multiplying the width of the source texture region by the x-axis scale factor. + /// + public float Width => Region.Width * Scale.X; + + /// + /// Gets the height, in pixels, of this sprite. + /// + /// + /// Height is calculated by multiplying the height of the source texture region by the y-axis scale factor. + /// + public float Height => Region.Height * Scale.Y; + #endregion + + #region ctors + /// + /// Creates a new sprite. + /// + public Sprite() { } + + /// + /// Creates a new sprite using the specified source texture region. + /// + /// The texture region to use as the source texture region for this sprite. + public Sprite(TextureRegion region) + { + Region = region; + } + #endregion + + #region methods + /// + /// Sets the origin of this sprite to the center + /// + public void CenterOrigin() + { + Origin = new Vector2(Region.Width, Region.Height) * 0.5f; + } + + /// + /// Submit this sprite for drawing to the current batch. + /// + /// The SpriteBatch instance used for batching draw calls. + /// The xy-coordinate position to render this sprite at. + public void Draw(SpriteBatch spriteBatch, Vector2 position) + { + Region.Draw(spriteBatch, position, Color, Rotation, Origin, Scale, Effects, LayerDepth); + } + #endregion +} diff --git a/articles/tutorials/building_2d_games/09_the_animatedsprite_class/images/bat-animation-example.gif b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/images/bat-animation-example.gif new file mode 100644 index 00000000..c23d07a3 Binary files /dev/null and b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/images/bat-animation-example.gif differ diff --git a/articles/tutorials/building_2d_games/09_the_animatedsprite_class/index.md b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/index.md new file mode 100644 index 00000000..caa011ce --- /dev/null +++ b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/index.md @@ -0,0 +1,234 @@ +--- +title: "Chapter 09: The AnimatedSprite Class" +description: "Create an AnimatedSprite class that builds upon our Sprite class to support frame-based animations." +--- + +While packing images into a texture atlas and managing them through our `Sprite` class improves performance and organization, games need animation to bring their visuals to life. 2D animation in games works much like a flip book; a sequence of individual images (*frames*) displayed in rapid succession creates the illusion of movement. Each frame represents a specific point in the sprite's animation, and when these frames are cycled through quickly, our eyes perceive fluid motion. + +> [!NOTE] +> The term "frame" in animation refers to a single image in an animation sequence. This is different from a game frame, which represents one complete render cycle of your game. + +In MonoGame, we can create these animations by cycling through different regions of our texture atlas, with each region representing a single frame of the animation. For example, *Figure 9-1* below shows three frames that make up a bat's wing-flapping animation: + +| ![Figure 9-1: Animation example of a bat flapping its wings](./images/bat-animation-example.gif) | +| :-----------------------------------------------------------------------------------------------: | +| **Figure 9-1: Animation example of a bat flapping its wings** | + +By drawing each frame sequentially over time, we create the illusion that the bat is flapping its wings. The speed at which we switch between frames determines how smooth or rapid the animation appears. + +In this chapter, we will build off of the `Sprite` class we created in [Chapter 08](../08_the_sprite_class/index.md) to create an `AnimatedSprite` class we can use to bring animations to life. + +## The Animation Class + +Before we can create animated sprites, we need a way to manage animation data. We will create an `Animation` class to encapsulate this information. In the *Graphics* folder within the *MonoGameLibrary* project, add a new file named `Animation.cs` with this initial structure: + +[!code-csharp[](./snippets/animation.cs#declaration)] + +### Animation Properties + +An animation requires two key pieces of information: the sequence of frames to display and the timing between them. Add these properties to the `Animation` class: + +[!code-csharp[](./snippets/animation.cs#members)] + +* The `Frames` property stores the collection of texture regions that make up the animation sequence. The order of regions in this collection is important; they will be displayed in the same sequence they are added, creating the animation's movement. For example, in our bat animation, the frames would be ordered to show the wings moving up, then fully extended, then down. + +* The `Delay` property defines how long each frame should be displayed before moving to the next one. This timing control allows us to adjust the speed of our animations; a shorter delay creates faster animations, while a longer delay creates slower ones. + +> [!NOTE] +> Using `TimeSpan` for the delay allows us to specify precise timing intervals, making it easier to synchronize animations with game time. In other scenarios, you could opt to just use `float` values instead. + +### Animation Constructors + +The `Animation` class provides two ways to create an animation. + +Add the following constructors: + +[!code-csharp[](./snippets/animation.cs#ctors)] + +* The default constructor creates an animation with an empty collection of frames and a default delay of 100 milliseconds between each frame. +* The parameterized constructor allows you to specify the frames of animation and the delay for the animation. + +> [!TIP] +> The default 100 milliseconds delay provides a good starting point for most animations, roughly equivalent to 10 animation frame changes per second. + +## Creating Animations With The TextureAtlas Class + +The `TextureAtlas` class we created in [Chapter 07](../07_optimizing_texture_rendering/index.md#the-textureatlas-class) can do more than just manage texture regions and create sprites; it can also store and manage animation data to create animated sprites with. The `atlas.png` image we are currently using contains the frames of animation for both a slime and a bat, as well as sprites for other things. We will first update our `atlas-definition.xml` file to include all regions in the atlas, as well as add new `` elements to define the animations. + +Open the `atlas-definition.xml` file in your code editor and replace the contents with the following: + +[!code-xml[](./snippets/atlas_definition.xml)] + +The key changes here are: + +- Regions have been added for all regions within the atlas. +- The slime and bat regions have been renamed to reflect the frame number of the animation. +- A new `` element has been added that defines `` elements. + +> [!NOTE] +> In the bat animation, we reuse frame "bat-1" in the sequence (bat-1, bat-2, bat-1, bat-3). This creates a smoother wing-flapping animation by returning to the neutral position between up and down wing positions. + +Now that we have a fully configured XML configuration for the atlas, we need to update the `TextureAtlas` class to manage animation data. Open the `TextureAtlas.cs` file and make the following changes: + +1. Add the following using statement so we can reference the `TimeSpan` struct: + + [!code-csharp[](./snippets/textureatlas/usings.cs?highlight=1)] + +2. Add storage for animations after the `Texture` property: + + [!code-csharp[](./snippets/textureatlas/add_animation_storage.cs)] + +3. Update the constructors so that the animations dictionary is initialized: + + [!code-csharp[](./snippets//textureatlas/update_ctors.cs?highlight=7,18)] + +4. Add methods to manage animations, similar to those that we use to manage regions: + + [!code-csharp[](./snippets/textureatlas/add_animation_management.cs)] + +5. Update the `FromFile` method to parse the new `` animation definitions from the XML configuration file + + [!code-csharp[](./snippets//textureatlas/update_from_file.cs?highlight=55-95)] + +The updated `FromFile` method now handles both region and animation definitions from the XML configuration. For animations, it: + +- Reads the `` section from the XML. +- For each animation: + - Gets the name and frame delay. + - Collects the referenced texture regions. + - Creates and stores a new `Animation` instance. + +## The AnimatedSprite Class + +With our `Animation` class handling animation data, and the `TextureAtlas` updated to store the animation data, we can now create a class that represents an animated sprites. Since an animated sprite is essentially a sprite that changes its texture region over time, we can build upon our existing `Sprite` class through [inheritance](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/tutorials/inheritance). + +> [!NOTE] +> By inheriting from `Sprite`, our `AnimatedSprite` class automatically gets all the rendering properties (position, rotation, scale, etc.) while adding new animation-specific functionality. + +The key to this design is the `Sprite.Region` property. Our `Sprite` class already knows how to render whatever region is currently set, so our `AnimatedSprite` class just needs to update this region property to the correct animation frame at the right time. + +We will now create the initial structure for our `AnimatedSprite` class. In the *Graphics* folder within the *MonoGameLibrary* project, add a new file named `AnimatedSprite.cs`: + +[!code-csharp[](./snippets/animatedsprite.cs#declaration)] + +### AnimatedSprite Members + +An animated sprite needs to track both its current animation state and timing information. Add the following members to the `AnimatedSprite` class: + +[!code-csharp[](./snippets/animatedsprite.cs#members)] + +The class uses three private fields to manage its animation state: + +- `_currentFrame`: Tracks which frame of the animation is currently being displayed. +- `_elapsed`: Keeps track of how much time has passed since the last frame change. +- `_animation`: Stores the current animation being played. + +The `Animation` property provides access to the current animation while ensuring the sprite always starts with the first frame when a new animation is set. When you assign a new animation, the property's setter automatically updates the sprite's region to display the first frame of that animation. + +> [!NOTE] +> Starting with the first frame when setting a new animation ensures consistent behavior when switching between different animations. + +### AnimatedSprite Constructors + +The `AnimatedSprite` class provides two ways to create an animated sprite. + +Add the following constructors: + +[!code-csharp[](./snippets/animatedsprite.cs#ctors)] + +* The default constructor creates an empty animated sprite that can be configured later. +* The parameterized constructor creates an animated sprite with a specified animation, which automatically sets the sprite's initial region to the first frame of that animation through the `Animation` property. + +> [!NOTE] +> Both constructors inherit from the base `Sprite` class, so an `AnimatedSprite` will have all the same rendering properties (position, rotation, scale, etc.) as a regular sprite. + +### AnimatedSprite Methods + +The `AnimatedSprite` class needs a way to update its animation state over time. This is handled by adding an `Update` method: + +[!code-csharp[](./snippets/animatedsprite.cs#methods)] + +The `Update` method manages the animation timing and frame progression: + +1. Accumulates the time passed since the last update in `_elapsed`. +2. When enough time has passed (defined by the animation's delay): + - Resets the elapsed time counter + - Advances to the next frame + - Loops back to the first frame if we have reached the end + - Updates the sprite's region to display the current frame + +> [!NOTE] +> Unlike the `Sprite` class which only needs a `Draw` method, the `AnimatedSprite` requires this additional `Update` method to handle frame changes over time. This follows MonoGame's update/draw pattern we first saw in [Chapter 03](../03_the_game1_file/index.md) + +The `Draw` method inherited from the base `Sprite` class remains unchanged, as it will automatically use whatever frame is currently set as the sprite's region. + +## Creating AnimatedSprites With The TextureAtlas Class + +Similar to the update we did to the `TextureAtlas` class in [Chapter 08](../08_the_sprite_class/index.md#create-sprites-with-the-textureatlas-class), creating an `AnimatedSprite` from the atlas would require + +1. Get the animation by name. +2. Store it in a variable. +3. Create a new animated sprite with that animation. + +We can simplify this process by adding an animated sprite creation method to the `TextureAtlas` class. Open `TextureAtlas.cs` and add the following method: + +[!code-csharp[](./snippets/textureatlas/create_animated_sprite.cs)] + +## Using the AnimatedSprite Class + +We can now adjust our game now to use the `AnimatedSprite` class to see our sprites come to life. Update the contents of `Game1.cs` with the following: + +[!code-csharp[](./snippets/game1.cs?highlight=11-15,34-40,48-52)] + +Here are the key changes in this implementation: + +- The `_slime` and `_bat` members were changed from `Sprite` to `AnimatedSprite`. +- In [**LoadContent**](xref:Microsoft.Xna.Framework.Game.LoadContent) the `_slime` and `_bat` sprites are now created using the new `TextureAtlas.CreateAnimatedSprite` method. +- In [**Update**](xref:Microsoft.Xna.Framework.Game.Update(Microsoft.Xna.Framework.GameTime)), the animations are updated based on the game time using the `AnimatedSprite.Update` method. + +Running the game now shows both sprites animating automatically: + +- The slime bounces between two frames +- The bat's wings flap in a continuous cycle + +| ![Figure 9-2: The slime and bat sprite animating](./videos/slime-bat-animated.webm) | +| :----------------------------------------------------------------------------------: | +| **Figure 9-2: The slime and bat sprite animating** | + +## Conclusion + +In this chapter, you accomplished the following: + +- Created an `Animation` class to manage frame sequences and timing. +- Extended the `TextureAtlas` class to support animation definitions. +- Built an `AnimatedSprite` class that inherits from `Sprite`. +- Applied inheritance to add animation capabilities while maintaining existing sprite functionality. +- Used XML configuration to define animations separately from code. + +Now that we can efficiently manage and render sprites and animations, in the next chapter we will start taking a look at user input. + +## Test Your Knowledge + +1. Why did we create a separate `Animation` class instead of putting animation properties directly in `AnimatedSprite`? + + :::question-answer + Separating animation data into its own class allows multiple `AnimatedSprite` instances to share the same animation definition. This is more efficient than each sprite having its own copy of the frame sequence and timing information. + ::: + +2. What is the benefit of using `TimeSpan` for animation delays instead of float values? + + :::question-answer + `TimeSpan` provides precise timing control and makes it easier to synchronize animations with game time. It also makes the delay values more explicit (milliseconds vs arbitrary numbers) and helps prevent timing errors. + ::: + +3. Why does the `AnimatedSprite` class need an `Update` method while the base `Sprite` class does not? + + :::question-answer + The `AnimatedSprite` needs to track elapsed time and change frames based on the animation's timing. This requires updating its state over time, while a regular sprite's appearance remains static until explicitly changed. + ::: + +4. In the `TextureAtlas` XML configuration, why might you want to reuse a frame in an animation sequence, like we did with the bat animation? + + :::question-answer + Reusing frames in an animation sequence can create smoother animations by providing transition states. In the bat animation, reusing the neutral position (bat-1) between wing movements creates a more natural flapping motion without requiring additional sprite frames. + ::: diff --git a/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/animatedsprite.cs b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/animatedsprite.cs new file mode 100644 index 00000000..73d745e2 --- /dev/null +++ b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/animatedsprite.cs @@ -0,0 +1,71 @@ +#region declaration +using System; +using Microsoft.Xna.Framework; + +namespace MonoGameLibrary.Graphics; + +public class AnimatedSprite : Sprite +{ + +} +#endregion +{ + #region members + private int _currentFrame; + private TimeSpan _elapsed; + private Animation _animation; + + /// + /// Gets or Sets the animation for this animated sprite. + /// + public Animation Animation + { + get => _animation; + set + { + _animation = value; + Region = _animation.Frames[0]; + } + } + #endregion + + #region ctors + /// + /// Creates a new animated sprite. + /// + public AnimatedSprite() { } + + /// + /// Creates a new animated sprite with the specified frames and delay. + /// + /// The animation for this animated sprite. + public AnimatedSprite(Animation animation) + { + Animation = animation; + } + #endregion + + #region methods + /// + /// Updates this animated sprite. + /// + /// A snapshot of the game timing values provided by the framework. + public void Update(GameTime gameTime) + { + _elapsed += gameTime.ElapsedGameTime; + + if (_elapsed >= _animation.Delay) + { + _elapsed -= _animation.Delay; + _currentFrame++; + + if (_currentFrame >= _animation.Frames.Count) + { + _currentFrame = 0; + } + + Region = _animation.Frames[_currentFrame]; + } + } + #endregion +} diff --git a/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/animation.cs b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/animation.cs new file mode 100644 index 00000000..637ea5cf --- /dev/null +++ b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/animation.cs @@ -0,0 +1,47 @@ +#region declaration +using System; +using System.Collections.Generic; + +namespace MonoGameLibrary.Graphics; + +public class Animation +{ + +} +#endregion +{ + #region members + /// + /// The texture regions that make up the frames of this animation. The order of the regions within the collection + /// are the order that the frames should be displayed in. + /// + public List Frames { get; set; } + + /// + /// The amount of time to delay between each frame before moving to the next frame for this animation. + /// + public TimeSpan Delay { get; set; } + #endregion + + #region ctors + /// + /// Creates a new animation. + /// + public Animation() + { + Frames = new List(); + Delay = TimeSpan.FromMilliseconds(100); + } + + /// + /// Creates a new animation with the specified frames and delay. + /// + /// An ordered collection of the frames for this animation. + /// The amount of time to delay between each frame of this animation. + public Animation(List frames, TimeSpan delay) + { + Frames = frames; + Delay = delay; + } + #endregion +} diff --git a/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/atlas_definition.xml b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/atlas_definition.xml new file mode 100644 index 00000000..f611c91e --- /dev/null +++ b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/atlas_definition.xml @@ -0,0 +1,23 @@ + + + images/atlas + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/game1.cs b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/game1.cs new file mode 100644 index 00000000..a790893a --- /dev/null +++ b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/game1.cs @@ -0,0 +1,76 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; + +namespace DungeonSlime; + +public class Game1 : Core +{ + // Defines the slime animated sprite. + private AnimatedSprite _slime; + + // Defines the bat animated sprite. + private AnimatedSprite _bat; + + public Game1() : base("Dungeon Slime", 1280, 720, false) + { + + } + + protected override void Initialize() + { + // TODO: Add your initialization logic here + + base.Initialize(); + } + + protected override void LoadContent() + { + // Create the texture atlas from the XML configuration file + TextureAtlas atlas = TextureAtlas.FromFile(Content, "images/atlas-definition.xml"); + + // Create the slime animated sprite from the atlas. + _slime = atlas.CreateAnimatedSprite("slime-animation"); + _slime.Scale = new Vector2(4.0f, 4.0f); + + // Create the bat animated sprite from the atlas. + _bat = atlas.CreateAnimatedSprite("bat-animation"); + _bat.Scale = new Vector2(4.0f, 4.0f); + } + + protected override void Update(GameTime gameTime) + { + if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape)) + Exit(); + + // Update the slime animated sprite. + _slime.Update(gameTime); + + // Update the bat animated sprite. + _bat.Update(gameTime); + + base.Update(gameTime); + } + + protected override void Draw(GameTime gameTime) + { + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + // Draw the slime sprite. + _slime.Draw(SpriteBatch, Vector2.One); + + // Draw the bat sprite 10px to the right of the slime. + _bat.Draw(SpriteBatch, new Vector2(_slime.Width + 10, 0)); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); + } +} diff --git a/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/tetureatlas.cs b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/tetureatlas.cs new file mode 100644 index 00000000..c89ab028 --- /dev/null +++ b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/tetureatlas.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; + +namespace MonoGameLibrary.Graphics; + +public class TextureAtlas +{ + private Dictionary _regions; + private Dictionary _animations; + + /// + /// Gets or Sets the source texture represented by this texture atlas. + /// + public Texture2D Texture { get; set; } + + /// + /// Creates a new texture atlas. + /// + public TextureAtlas() + { + _regions = new Dictionary(); + _animations = new Dictionary(); + } + + /// + /// Creates a new texture atlas instance using the given texture. + /// + /// The source texture represented by the texture atlas. + public TextureAtlas(Texture2D texture) + { + Texture = texture; + _regions = new Dictionary(); + _animations = new Dictionary(); + } + + /// + /// Creates a new region and adds it to this texture atlas. + /// + /// The name to give the texture region. + /// The top-left x-coordinate position of the region boundary relative to the top-left corner of the source texture boundary. + /// The top-left y-coordinate position of the region boundary relative to the top-left corner of the source texture boundary. + /// The width, in pixels, of the region. + /// The height, in pixels, of the region. + public void AddRegion(string name, int x, int y, int width, int height) + { + TextureRegion region = new TextureRegion(Texture, x, y, width, height); + _regions.Add(name, region); + } + + /// + /// Gets the region from this texture atlas with the specified name. + /// + /// The name of the region to retrieve. + /// The TextureRegion with the specified name. + public TextureRegion GetRegion(string name) + { + return _regions[name]; + } + + /// + /// Removes the region from this texture atlas with the specified name. + /// + /// The name of the region to remove. + /// + public bool RemoveRegion(string name) + { + return _regions.Remove(name); + } + + /// + /// Removes all regions from this texture atlas. + /// + public void Clear() + { + _regions.Clear(); + } + + /// + /// Creates a new sprite using the region from this texture atlas with the specified name. + /// + /// The name of the region to create the sprite with. + /// A new Sprite using the texture region with the specified name. + public Sprite CreateSprite(string regionName) + { + TextureRegion region = GetRegion(regionName); + return new Sprite(region); + } + + /// + /// Adds the given animation to this texture atlas with the specified name. + /// + /// The name of the animation to add. + /// The animation to add. + public void AddAnimation(string animationName, Animation animation) + { + _animations.Add(animationName, animation); + } + + /// + /// Gets the animation from this texture atlas with the specified name. + /// + /// The name of the animation to retrieve. + /// The animation with the specified name. + public Animation GetAnimation(string animationName) + { + return _animations[animationName]; + } + + /// + /// Removes the animation with the specified name from this texture atlas. + /// + /// The name of the animation to remove. + /// true if the animation is removed successfully; otherwise, false. + public bool RemoveAnimation(string animationName) + { + return _animations.Remove(animationName); + } + + /// + /// Creates a new texture atlas based a texture atlas xml configuration file. + /// + /// The content manager used to load the texture for the atlas. + /// The path to the xml file, relative to the content root directory.. + /// The texture atlas created by this method. + public static TextureAtlas FromFile(ContentManager content, string fileName) + { + TextureAtlas atlas = new TextureAtlas(); + + string filePath = Path.Combine(content.RootDirectory, fileName); + + using (Stream stream = TitleContainer.OpenStream(filePath)) + { + using (XmlReader reader = XmlReader.Create(stream)) + { + XDocument doc = XDocument.Load(reader); + XElement root = doc.Root; + + // The element contains the content path for the Texture2D to load. + // So we will retrieve that value then use the content manager to load the texture. + string texturePath = root.Element("Texture").Value; + atlas.Texture = content.Load(texturePath); + + // The element contains individual elements, each one describing + // a different texture region within the atlas. + // + // Example: + // + // + // + // + // + // So we retrieve all of the elements then loop through each one + // and generate a new TextureRegion instance from it and add it to this atlas. + var regions = root.Element("Regions")?.Elements("Region"); + + if (regions != null) + { + foreach (var region in regions) + { + string name = region.Attribute("name")?.Value; + int x = int.Parse(region.Attribute("x")?.Value ?? "0"); + int y = int.Parse(region.Attribute("y")?.Value ?? "0"); + int width = int.Parse(region.Attribute("width")?.Value ?? "0"); + int height = int.Parse(region.Attribute("height")?.Value ?? "0"); + + if (!string.IsNullOrEmpty(name)) + { + atlas.AddRegion(name, x, y, width, height); + } + } + } + + // The element contains individual elements, each one describing + // a different animation within the atlas. + // + // Example: + // + // + // + // + // + // + // + // So we retrieve all of the elements then loop through each one + // and generate a new Animation instance from it and add it to this atlas. + var animationElements = root.Element("Animations").Elements("Animation"); + + if (animationElements != null) + { + foreach (var animationElement in animationElements) + { + string name = animationElement.Attribute("name")?.Value; + float delayInMilliseconds = float.Parse(animationElement.Attribute("delay")?.Value ?? "0"); + TimeSpan delay = TimeSpan.FromMilliseconds(delayInMilliseconds); + + List frames = new List(); + + var frameElements = animationElement.Elements("Frame"); + + if (frameElements != null) + { + foreach (var frameElement in frameElements) + { + string regionName = frameElement.Attribute("region").Value; + TextureRegion region = atlas.GetRegion(regionName); + frames.Add(region); + } + } + + Animation animation = new Animation(frames, delay); + atlas.AddAnimation(name, animation); + } + } + + return atlas; + } + } + } +} diff --git a/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/add_animation_management.cs b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/add_animation_management.cs new file mode 100644 index 00000000..b8e3d63b --- /dev/null +++ b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/add_animation_management.cs @@ -0,0 +1,29 @@ +/// +/// Adds the given animation to this texture atlas with the specified name. +/// +/// The name of the animation to add. +/// The animation to add. +public void AddAnimation(string animationName, Animation animation) +{ + _animations.Add(animationName, animation); +} + +/// +/// Gets the animation from this texture atlas with the specified name. +/// +/// The name of the animation to retrieve. +/// The animation with the specified name. +public Animation GetAnimation(string animationName) +{ + return _animations[animationName]; +} + +/// +/// Removes the animation with the specified name from this texture atlas. +/// +/// The name of the animation to remove. +/// true if the animation is removed successfully; otherwise, false. +public bool RemoveAnimation(string animationName) +{ + return _animations.Remove(animationName); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/add_animation_storage.cs b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/add_animation_storage.cs new file mode 100644 index 00000000..8b2fe7e9 --- /dev/null +++ b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/add_animation_storage.cs @@ -0,0 +1,2 @@ +// Stores animations added to this atlas. +private Dictionary _animations; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/create_animated_sprite.cs b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/create_animated_sprite.cs new file mode 100644 index 00000000..0dac322a --- /dev/null +++ b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/create_animated_sprite.cs @@ -0,0 +1,10 @@ +/// +/// Creates a new animated sprite using the animation from this texture atlas with the specified name. +/// +/// The name of the animation to use. +/// A new AnimatedSprite using the animation with the specified name. +public AnimatedSprite CreateAnimatedSprite(string animationName) +{ + Animation animation = GetAnimation(animationName); + return new AnimatedSprite(animation); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/update_ctors.cs b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/update_ctors.cs new file mode 100644 index 00000000..ece920a3 --- /dev/null +++ b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/update_ctors.cs @@ -0,0 +1,19 @@ +/// +/// Creates a new texture atlas. +/// +public TextureAtlas() +{ + _regions = new Dictionary(); + _animations = new Dictionary(); +} + +/// +/// Creates a new texture atlas instance using the given texture. +/// +/// The source texture represented by the texture atlas. +public TextureAtlas(Texture2D texture) +{ + Texture = texture; + _regions = new Dictionary(); + _animations = new Dictionary(); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/update_from_file.cs b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/update_from_file.cs new file mode 100644 index 00000000..1a1592ba --- /dev/null +++ b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/update_from_file.cs @@ -0,0 +1,100 @@ +/// +/// Creates a new texture atlas based a texture atlas xml configuration file. +/// +/// The content manager used to load the texture for the atlas. +/// The path to the xml file, relative to the content root directory.. +/// The texture atlas created by this method. +public static TextureAtlas FromFile(ContentManager content, string fileName) +{ + TextureAtlas atlas = new TextureAtlas(); + + string filePath = Path.Combine(content.RootDirectory, fileName); + + using (Stream stream = TitleContainer.OpenStream(filePath)) + { + using (XmlReader reader = XmlReader.Create(stream)) + { + XDocument doc = XDocument.Load(reader); + XElement root = doc.Root; + + // The element contains the content path for the Texture2D to load. + // So we will retrieve that value then use the content manager to load the texture. + string texturePath = root.Element("Texture").Value; + atlas.Texture = content.Load(texturePath); + + // The element contains individual elements, each one describing + // a different texture region within the atlas. + // + // Example: + // + // + // + // + // + // So we retrieve all of the elements then loop through each one + // and generate a new TextureRegion instance from it and add it to this atlas. + var regions = root.Element("Regions")?.Elements("Region"); + + if (regions != null) + { + foreach (var region in regions) + { + string name = region.Attribute("name")?.Value; + int x = int.Parse(region.Attribute("x")?.Value ?? "0"); + int y = int.Parse(region.Attribute("y")?.Value ?? "0"); + int width = int.Parse(region.Attribute("width")?.Value ?? "0"); + int height = int.Parse(region.Attribute("height")?.Value ?? "0"); + + if (!string.IsNullOrEmpty(name)) + { + atlas.AddRegion(name, x, y, width, height); + } + } + } + + // The element contains individual elements, each one describing + // a different animation within the atlas. + // + // Example: + // + // + // + // + // + // + // + // So we retrieve all of the elements then loop through each one + // and generate a new Animation instance from it and add it to this atlas. + var animationElements = root.Element("Animations").Elements("Animation"); + + if (animationElements != null) + { + foreach (var animationElement in animationElements) + { + string name = animationElement.Attribute("name")?.Value; + float delayInMilliseconds = float.Parse(animationElement.Attribute("delay")?.Value ?? "0"); + TimeSpan delay = TimeSpan.FromMilliseconds(delayInMilliseconds); + + List frames = new List(); + + var frameElements = animationElement.Elements("Frame"); + + if (frameElements != null) + { + foreach (var frameElement in frameElements) + { + string regionName = frameElement.Attribute("region").Value; + TextureRegion region = atlas.GetRegion(regionName); + frames.Add(region); + } + } + + Animation animation = new Animation(frames, delay); + atlas.AddAnimation(name, animation); + } + } + + return atlas; + } + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/usings.cs b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/usings.cs new file mode 100644 index 00000000..372496f2 --- /dev/null +++ b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/snippets/textureatlas/usings.cs @@ -0,0 +1,8 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; diff --git a/articles/tutorials/building_2d_games/09_the_animatedsprite_class/videos/slime-bat-animated.webm b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/videos/slime-bat-animated.webm new file mode 100644 index 00000000..7637f565 Binary files /dev/null and b/articles/tutorials/building_2d_games/09_the_animatedsprite_class/videos/slime-bat-animated.webm differ diff --git a/articles/tutorials/building_2d_games/10_handling_input/images/ps-controller-back.svg b/articles/tutorials/building_2d_games/10_handling_input/images/ps-controller-back.svg new file mode 100644 index 00000000..4a9f39ba --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/images/ps-controller-back.svg @@ -0,0 +1,206 @@ + + + +Right ShoulderLeft ShoulderLeft TriggerRight Trigger diff --git a/articles/tutorials/building_2d_games/10_handling_input/images/ps-controller-front.svg b/articles/tutorials/building_2d_games/10_handling_input/images/ps-controller-front.svg new file mode 100644 index 00000000..cb6b268f --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/images/ps-controller-front.svg @@ -0,0 +1,3 @@ + + +xLeft ThumbstickRight ShoulderLeft ShoulderXBAYStartBackDPadRight Thumbstick diff --git a/articles/tutorials/building_2d_games/10_handling_input/images/xbox-controller-back.svg b/articles/tutorials/building_2d_games/10_handling_input/images/xbox-controller-back.svg new file mode 100644 index 00000000..d00541b2 --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/images/xbox-controller-back.svg @@ -0,0 +1,129 @@ + + + +Left ShoulderRight ShoulderLeft TriggerRight Trigger diff --git a/articles/tutorials/building_2d_games/10_handling_input/images/xbox-controller-front.svg b/articles/tutorials/building_2d_games/10_handling_input/images/xbox-controller-front.svg new file mode 100644 index 00000000..338ce85f --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/images/xbox-controller-front.svg @@ -0,0 +1,2 @@ + +Left ThumbstickRight ThumbstickDPadBackStartYABXLeft ShoulderRight ShoulderABXY diff --git a/articles/tutorials/building_2d_games/10_handling_input/index.md b/articles/tutorials/building_2d_games/10_handling_input/index.md new file mode 100644 index 00000000..fb358e86 --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/index.md @@ -0,0 +1,473 @@ +--- +title: "Chapter 10: Handling Input" +description: "Learn how to handle keyboard, mouse, and gamepad input in MonoGame." +--- + +When you play a game, you need ways to control what is happening; using a keyboard or gamepad to control a character or clicking the mouse to navigate a menu, MonoGame helps us handle all these different types of controls through dedicated input classes: + +- [**Keyboard**](xref:Microsoft.Xna.Framework.Input.Keyboard): Detects which keys are being pressed. +- [**Mouse**](xref:Microsoft.Xna.Framework.Input.Mouse): Tracks mouse movement, button clicks, and scroll wheel use. +- [**GamePad**](xref:Microsoft.Xna.Framework.Input.GamePad): Manages controller input like button presses and thumbstick movement. +- [**TouchPanel**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel): Manages touch input on devices with a touch panel such as mobile phones and tablets. + +Each of these input types has a `GetState` method that, when called, checks what is happening with that device at that moment. Think of it like taking a snapshot; when you call `GetState`, MonoGame looks at that exact moment to see which buttons are pressed, where the mouse is, or how the controller is being used. + +In this chapter you will, we will learn how to use each of these dedicated input classes to handle player input. + +## Keyboard Input + +The keyboard is often the primary input device for PC games, used for everything from character movement to menu navigation. MonoGame provides the [**Keyboard**](xref:Microsoft.Xna.Framework.Input.Keyboard) class to handle keyboard input, making it easy to detect which keys are being pressed at any time. Calling [**Keyboard.GetState**](xref:Microsoft.Xna.Framework.Input.Keyboard.GetState) will retrieve the current state of the keyboard as a [**KeyboardState**](xref:Microsoft.Xna.Framework.Input.KeyboardState) struct. + +### KeyboardState Struct + +The [**KeyboardState**](xref:Microsoft.Xna.Framework.Input.KeyboardState) struct contains methods that can be used to determine if a keyboard key is currently down or up: + +| Method | Description | +|-----------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------| +| [**IsKeyDown(Keys)**](xref:Microsoft.Xna.Framework.Input.KeyboardState.IsKeyDown(Microsoft.Xna.Framework.Input.Keys)) | Returns `true` if the specified key is down; otherwise, returns `false`. | +| [**IsKeyUp(Keys)**](xref:Microsoft.Xna.Framework.Input.KeyboardState.IsKeyUp(Microsoft.Xna.Framework.Input.Keys)) | Returns `true` if the specified key is up; otherwise, returns `false`. | + +For example, if we wanted to see if the Space key is down, you could use the following: + +[!code-csharp[](./snippets/keyboardstate.cs)] + +> [!TIP] +> Notice we store the keyboard state in a variable instead of calling [**Keyboard.GetState**](xref:Microsoft.Xna.Framework.Input.Keyboard.GetState) multiple times. This is more efficient and ensures consistent input checking within a single frame. + +## Mouse Input + +The mouse is often the secondary input device for PC games, used for various actions from camera movement to interacting with menus and objects. MonoGame provides the [**Mouse**](xref:Microsoft.Xna.Framework.Input.Mouse) class to handle mouse input, making it easy to detect which buttons are pressed, the position of the mouse cursor, and the value of the scroll wheel. Calling [**Mouse.GetState**](xref:Microsoft.Xna.Framework.Input.Mouse.GetState) will retrieve the current state of the mouse as a [**MouseState**](xref:Microsoft.Xna.Framework.Input.MouseState) struct. + +### MouseState Struct + +The [**MouseState**](xref:Microsoft.Xna.Framework.Input.MouseState) struct contains properties that can be used to determine the state of the mouse buttons, the mouse position, and the scroll wheel value: + +| Property | Type | Description | +|----------------------------------------------------------------------------------------|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------| +| [**LeftButton**](xref:Microsoft.Xna.Framework.Input.MouseState.LeftButton) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the left mouse button. | +| [**MiddleButton**](xref:Microsoft.Xna.Framework.Input.MouseState.MiddleButton) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the middle mouse button. This is often the button when pressing the scroll wheel down as a button | +| [**Position**](xref:Microsoft.Xna.Framework.Input.MouseState.Position) | [**Point**](xref:Microsoft.Xna.Framework.Point) | Returns the position of the mouse cursor relative to the bounds of the game window. | +| [**RightButton**](xref:Microsoft.Xna.Framework.Input.MouseState.RightButton) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the right mouse button. | +| [**ScrollWheelValue**](xref:Microsoft.Xna.Framework.Input.MouseState.ScrollWheelValue) | `int` | Returns the **cumulative** scroll wheel value since the start of the game | +| [**XButton1**](xref:Microsoft.Xna.Framework.Input.MouseState.XButton1) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the first extended button on the mouse. | +| [**XButton2**](xref:Microsoft.Xna.Framework.Input.MouseState.XButton2) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the second extended button on the mouse. | + +> [!NOTE] +> [**ScrollWheelValue**](xref:Microsoft.Xna.Framework.Input.MouseState.ScrollWheelValue) returns the cumulative value of the scroll wheel since the start of the game, not how much it moved since the last update. To determine how much it moved between one update and the next, you would need to compare it with the previous frame's value. We will discuss comparing previous and current frame values for inputs in the next chapter. + +Unlike keyboard input which uses [**IsKeyDown(Keys)**](xref:Microsoft.Xna.Framework.Input.KeyboardState.IsKeyDown(Microsoft.Xna.Framework.Input.Keys))/[**IsKeyUp(Keys)**](xref:Microsoft.Xna.Framework.Input.KeyboardState.IsKeyUp(Microsoft.Xna.Framework.Input.Keys)) methods mouse buttons return a [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState): + +- [**ButtonState.Pressed**](xref:Microsoft.Xna.Framework.Input.ButtonState): The button is being held down. +- [**ButtonState.Released**](xref:Microsoft.Xna.Framework.Input.ButtonState): The button is not being pressed. + +For example, if we wanted to see if the left mouse button is down, you could use the following + +[!code-csharp[](./snippets/mousestate.cs)] + +## Gamepad Input + +Gamepads are often used as a primary input for a game or an alternative for keyboard and mouse controls. MonoGame provides the [**GamePad**](xref:Microsoft.Xna.Framework.Input.GamePad) class to handle gamepad input, making it easy to detect which buttons are pressed and the value of the thumbsticks. Calling [**GamePad.GetState**](xref:Microsoft.Xna.Framework.Input.GamePad.GetState(Microsoft.Xna.Framework.PlayerIndex)) will retrieve the state of the gamepad as a [**GamePadState**](xref:Microsoft.Xna.Framework.Input.GamePadState) struct. Since multiple gamepads can be connected, you will need to supply a [**PlayerIndex**](xref:Microsoft.Xna.Framework.PlayerIndex) value to specify which gamepad state to retrieve. + +### GamePadState Struct + +The [**GamePadState**](xref:Microsoft.Xna.Framework.Input.GamePadState) struct and properties that can be used to get the state of the buttons, dpad, triggers, and thumbsticks: + +| Property | Type | Description | +|--------------------------------------------------------------------------------|---------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [**Buttons**](xref:Microsoft.Xna.Framework.Input.GamePadState.Buttons) | [**GamePadButtons**](xref:Microsoft.Xna.Framework.Input.GamePadButtons) | Returns a struct that identifies which buttons on the controller are pressed. | +| [**DPad**](xref:Microsoft.Xna.Framework.Input.GamePadState.DPad) | [**GamePadDPad**](xref:Microsoft.Xna.Framework.Input.GamePadDPad) | Returns a struct that identifies which directions on the DPad are pressed. | +| [**IsConnected**](xref:Microsoft.Xna.Framework.Input.GamePadState.IsConnected) | `bool` | Returns a value that indicates whether the controller is connected. | +| [**ThumbSticks**](xref:Microsoft.Xna.Framework.Input.GamePadState.ThumbSticks) | [**GamePadThumbSticks**](xref:Microsoft.Xna.Framework.Input.GamePadThumbSticks) | Returns a struct that contains the direction of each thumbstick. Each thumbstick (left and right) are represented as a [**Vector2**](xref:Microsoft.Xna.Framework.Vector2) value between `-1.0f` and `1.0` for the x- and y-axes. | +| [**Triggers**](xref:Microsoft.Xna.Framework.Input.GamePadState.Triggers) | [**GamePadTriggers**](xref:Microsoft.Xna.Framework.Input.GamePadTriggers) | Returns a struct that contains the value of each trigger. Each trigger (left and right) are represented as a `float` value between `0.0f`, meaning not pressed, and `1.0f`, meaning fully pressed. | + +#### Buttons + +The [**GamePadState.Buttons**](xref:Microsoft.Xna.Framework.Input.GamePadState.Buttons) property returns a [**GamePadButtons**](xref:Microsoft.Xna.Framework.Input.GamePadButtons) struct that can be used to identify which buttons on the controller are pressed. This struct contains the following properties: + +| Property | Type | Description | +|--------------------------------------------------------------------------------------|-------------------------------------------------------------------|-----------------------------------------------| +| [**A**](xref:Microsoft.Xna.Framework.Input.GamePadButtons.A) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the A button | +| [**B**](xref:Microsoft.Xna.Framework.Input.GamePadButtons.B) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the B button | +| [**Back**](xref:Microsoft.Xna.Framework.Input.GamePadButtons.Back) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the Back button | +| [**BigButton**](xref:Microsoft.Xna.Framework.Input.GamePadButtons.BigButton) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the BigButton button | +| [**LeftShoulder**](xref:Microsoft.Xna.Framework.Input.GamePadButtons.LeftShoulder) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the LeftShoulder button | +| [**LeftStick**](xref:Microsoft.Xna.Framework.Input.GamePadButtons.LeftStick) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the LeftStick button | +| [**RightShoulder**](xref:Microsoft.Xna.Framework.Input.GamePadButtons.RightShoulder) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the RightShoulder button | +| [**RightStick**](xref:Microsoft.Xna.Framework.Input.GamePadButtons.RightStick) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the RightStick button | +| [**Start**](xref:Microsoft.Xna.Framework.Input.GamePadButtons.Start) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the Start button | +| [**X**](xref:Microsoft.Xna.Framework.Input.GamePadButtons.X) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the X button | +| [**Y**](xref:Microsoft.Xna.Framework.Input.GamePadButtons.Y) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the Y button | + +> [!NOTE] +> Recall from [Chapter 01](../01_what_is_monogame/index.md) that MonoGame is a implementation the XNA API. Since XNA was originally created for making games on Windows PC and Xbox 360, the names of the gamepad buttons match those of an Xbox 360 controller. +> +> The [**BigButton**](xref:Microsoft.Xna.Framework.Input.GamePadButtons.BigButton) refers to the large, centrally located button on special Xbox 360 controllers created for games like "Scene It?" - this button is not present on standard controllers and is not mapped to any button on modern controllers. It remains in the API for backward compatibility with XNA. +> +> | Front | Back | +> | :--------------------------------------------------------- | :------------------------------------------------------- | +> | Xbox | | +> | ![Front Of Controller](./images/xbox-controller-front.svg) | ![Back Of Controller](./images/xbox-controller-back.svg) | +> | Playstation | | +> | ![Front Of Controller](./images/ps-controller-front.svg) | ![Back Of Controller](./images/ps-controller-back.svg) | + +Like with the [mouse input](#mousestate-struct), each of these buttons are represented by a [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) enum value. For instance, if you wanted to check if the A button is being pressed you could do the following: + +[!code-csharp[](./snippets/gamepadstate.cs)] + +> [!NOTE] +> You may notice however, that the GamePadState also requires a controller index, as more than one can be connected at the same time. The latest Xbox console for instance can support up to 8 controllers at a time, for this reason you need to specify which controller you are listening for. Additionally, if you want ANY controller to start your game, you will need to loop through all possible controllers each frame until the first one "picks up". + +#### DPad + +The [**DPad**](xref:Microsoft.Xna.Framework.Input.GamePadState.DPad) property returns a [**GamePadDPad**](xref:Microsoft.Xna.Framework.Input.GamePadDPad) struct that can be used to identify which DPad buttons on the controller are pressed. This struct contains the following properties: + +| Property | Type | Description | +|------------------------------------------------------------------|-------------------------------------------------------------------|---------------------------------------------| +| [**Down**](xref:Microsoft.Xna.Framework.Input.GamePadDPad.Down) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the DPad Down button. | +| [**Left**](xref:Microsoft.Xna.Framework.Input.GamePadDPad.Down) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the DPad Left button. | +| [**Right**](xref:Microsoft.Xna.Framework.Input.GamePadDPad.Down) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the DPad Right button. | +| [**Up**](xref:Microsoft.Xna.Framework.Input.GamePadDPad.Down) | [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) | Returns the state of the DPad Up Button. | + +Like with the [Buttons](#buttons), these also return a [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) enum value to represent the state of the DPad button. For instance, if you wanted to check if the DPad up button is being pressed, you could do the following: + +[!code-csharp[](./snippets/buttonstate.cs)] + +#### Thumbsticks + +The [**ThumbSticks**](xref:Microsoft.Xna.Framework.Input.GamePadState.ThumbSticks) property returns a [**GamePadThumbSticks**](xref:Microsoft.Xna.Framework.Input.GamePadThumbSticks) struct that can be used to retrieve the values of the left and right thumbsticks. This struct contains the following properties: + +| Property | Type | Description | +|--------------------------------------------------------------------------|-----------------------------------------------------|------------------------------------------------| +| [**Left**](xref:Microsoft.Xna.Framework.Input.GamePadThumbSticks.Left) | [**Vector2**](xref:Microsoft.Xna.Framework.Vector2) | The direction the left thumbstick is pressed. | +| [**Right**](xref:Microsoft.Xna.Framework.Input.GamePadThumbSticks.Right) | [**Vector2**](xref:Microsoft.Xna.Framework.Vector2) | The direction the right thumbstick is pressed. | + +The thumbstick values are represented as a [**Vector2**](xref:Microsoft.Xna.Framework.Vector2) value: + +- X-axis: A value between `-1.0f` (pushed fully to the left) and `1.0f` (pushed fully to the right). +- Y-axis: A value between `-1.0f` (pushed fully downward) and `1.0f` (pushed fully upward). + +For example, if you wanted to move a sprite using the left thumbstick, you could do the following: + +[!code-csharp[](./snippets/thumbstick.cs)] + +> [!IMPORTANT] +> Notice that we inverted the y-axis value of the thumbstick by multiplying it by `-1.0f`. This is necessary because the thumbstick y-axis values range from `-1.0f` (down) to `1.0f` (up). The y-axis of the screen coordinates in MonoGame **increases** downward, as we saw in [Chapter 06](../06_working_with_textures/index.md#drawing-a-texture). +> +> This inversion aligns the thumbstick's y-axis value with the screen movement. + +#### Triggers + +The [**Triggers**](xref:Microsoft.Xna.Framework.Input.GamePadState.Triggers) property returns a [**GamePadTriggers**](xref:Microsoft.Xna.Framework.Input.GamePadTriggers) struct that can be used to retrieve the values of the left and right triggers. This struct contains the following properties: + +| Property | Type | Description | +|-----------------------------------------------------------------------|---------|--------------------------------| +| [**Left**](xref:Microsoft.Xna.Framework.Input.GamePadTriggers.Left) | `float` | The value of the left trigger. | +| [**Right**](xref:Microsoft.Xna.Framework.Input.GamePadTriggers.Right) | `float` | The value of the left trigger. | + +The trigger values are represented as a float value between `0.0f` (not pressed) to `1.0f` (fully pressed). The triggers on a gamepad, however, can be either *analog* or *digital* depending the gamepad manufacturer. For gamepads with *digital* triggers, the value will always be either `0.0f` or `1.0f`, as a digital trigger does not register values in between based on the amount of pressure applied to the trigger. + +For example, if we were creating a racing game, the right trigger could be used for acceleration like the following: + +[!code-csharp[](./snippets/triggers.cs)] + +### GamePadState Methods + +The [**GamePadState**](xref:Microsoft.Xna.Framework.Input.GamePadState) struct also contains two methods that can be used to get information about the device's inputs as either being up or down: + +| Method | Description | +|----------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [**IsButtonDown(Buttons)**](xref:Microsoft.Xna.Framework.Input.GamePadState.IsButtonDown(Microsoft.Xna.Framework.Input.Buttons)) | Returns a value that indicates whether the specified button is down. Multiple [**Buttons**](xref:Microsoft.Xna.Framework.Input.Buttons) values can be given using the bitwise OR `|` operator. When multiple buttons are given, the return value indicates if all buttons specified are down, not just one of them. | +| [**IsButtonUp(Buttons)**](xref:Microsoft.Xna.Framework.Input.GamePadState.IsButtonUp(Microsoft.Xna.Framework.Input.Buttons)) | Returns a value that indicates whether the specified button is up. Multiple [**Buttons**](xref:Microsoft.Xna.Framework.Input.Buttons) values can be given using the bitwise OR `|` operator. When multiple buttons are given, the return value indicates if all buttons specified are up, not just one of them. | + +You can use the [**IsButtonDown(Buttons)**](xref:Microsoft.Xna.Framework.Input.GamePadState.IsButtonDown(Microsoft.Xna.Framework.Input.Buttons)) and [**IsButtonUp(Buttons)**](xref:Microsoft.Xna.Framework.Input.GamePadState.IsButtonUp(Microsoft.Xna.Framework.Input.Buttons)) methods to get the state of all buttons, including the DPad. The following is a complete list of all of the [**Buttons**](xref:Microsoft.Xna.Framework.Input.Buttons) enum values: + +- [**Buttons.A**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.B**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.Back**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.BigButton**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.DPadDown**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.DPadLeft**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.DPadRight**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.DPadUp**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.LeftShoulder**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.LeftStick**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.LeftThumbstickDown**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.LeftThumbstickLeft**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.LeftThumbstickRight**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.LeftThumbstickUp**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.LeftTrigger**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.None**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.RightShoulder**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.RightStick**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.RightStickDown**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.RightStickLeft**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.RightStickRight**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.RightStickUp**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.RightTrigger**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.Start**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.X**](xref:Microsoft.Xna.Framework.Input.Buttons) +- [**Buttons.Y**](xref:Microsoft.Xna.Framework.Input.Buttons) + +> [!CAUTION] +> While you can use these methods to get the state of any of these button inputs, the state will only tell you if it is being pressed or released. For the actual thumbstick values and trigger values, you would need to use the properties instead. + +For example, if we wanted to check if the A button on the the first gamepad is pressed, you could use the following: + +[!code-csharp[](./snippets/isbuttondown.cs)] + +### GamePad Vibration + +Another capability of gamepads is haptic feedback through vibration motors. MonoGame allows you to control this feature using the [**GamePad.SetVibration**](xref:Microsoft.Xna.Framework.Input.GamePad.SetVibration(Microsoft.Xna.Framework.PlayerIndex,System.Single,System.Single)) method. This method takes three parameters: + +1. The [**PlayerIndex**](xref:Microsoft.Xna.Framework.PlayerIndex) of the gamepad to vibrate. +2. The intensity of the left motor (from `0.0f` for no vibration to `1.0f` for maximum vibration). +3. The intensity of the right motor (using the same scale). + +Most modern gamepads have two vibration motors, a larger one (usually the left motor) for low-frequency rumble and a smaller one (usually the right motor) for high-frequency feedback. By controlling these independently, you can create various haptic effects: + +[!code-csharp[](./snippets/vibration.cs)] + +## TouchPanel Input + +For mobile devices such as Android/iOS phones and tablets, the primary input device is the touch panel screen. Touching a location on the screen is similar to clicking a location on your computer with a mouse. MonoGame provides the [**TouchPanel**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel) class to handle touch input. + +The [**TouchPanel**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel) class offers two ways of retrieving information about touch input: + +- [**TouchPanel.GetState**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel.GetState) retrieves a [**TouchCollection**](xref:Microsoft.Xna.Framework.Input.Touch.TouchCollection) struct that contains [**TouchLocation**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation) values for each point of touch on the touch panel. +- [**TouchPanel.ReadGesture**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel.ReadGesture) retrieves a [**GestureSample**](xref:Microsoft.Xna.Framework.Input.Touch.GestureSample) struct that contains information about recent gestures that have been performed like a vertical or horizontal drag across the screen. + +### TouchCollection + +When calling [**TouchPanel.GetState**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel.GetState) a [**TouchCollection**](xref:Microsoft.Xna.Framework.Input.Touch.TouchCollection) struct is returned. This collection contains a [**TouchLocation**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation) value for each point of touch. + +#### TouchLocation + +Each [**TouchLocation**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation) value in a touch collection contains the following properties: + +| Property | Type | Description | +|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------| +| [**Id**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation.Id) | `int` | The id of the touch location. | +| [**Position**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation.Position) | [**Vector2**](xref:Microsoft.Xna.Framework.Vector2) | The position of the touch location. | +| [**Pressure**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation.Pressure) | `float` | The amount of pressure applied at the touch location. **(Only available for Android devices.)** | +| [**State**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation.State) | [**TouchLocationState**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocationState) | The current state of the touch location. | + +The important properties of the location are the [**Position**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation.Position) and the [**State**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation.State) The position property will tell us the location of the touch event, and the state can be one of the following values: + +| State | Description | +|------------------------------------------------------------------------------|---------------------------------------------------------------------------| +| [**Invalid**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation.State) | This touch location position is invalid. | +| [**Moved**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation.State) | This touch location position was updated or pressed at the same position. | +| [**Pressed**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation.State) | This touch location was pressed. | +| [**Released**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation.State) | This touch location was released. | + +When the state is moved or pressed, then we know that location on the touch panel is being touched. So we can capture it and use it like the following: + +[!code-csharp[](./snippets/touchstate.cs)] + +> [!NOTE] +> Unlike mouse input which only tracks a single point, [**TouchPanel**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel) supports multiple simultaneous touch points. The [**TouchCollection**](xref:Microsoft.Xna.Framework.Input.Touch.TouchCollection) contains all active touch points, which is why we loop through them in the sample above. + +The state of a touch location progresses through the states typically in order of: + +- [**Pressed**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation.State): Initial contact with the screen. +- [**Moved**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation.State) : Touch point moved while maintaining contact. +- [**Released**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation.State): Contact with screen ended. +- [**Invalid**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation.State) : Touch data is invalid (using when tracking data is lost). + +### GestureSample + +When calling [**TouchPanel.ReadGesture**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel.ReadGesture) a [**GestureSample**](xref:Microsoft.Xna.Framework.Input.Touch.GestureSample) struct containing the information about recent gestures that have been performed is returned. The [**GestureSample**](xref:Microsoft.Xna.Framework.Input.Touch.GestureSample) struct contains the following properties: + +| Property | Type | Description | +|---------------------------------------------------------------------------------------|-------------------------------------------------------------------------|--------------------------------------------------------------------------------| +| [**Delta**](xref:Microsoft.Xna.Framework.Input.Touch.GestureSample.Delta) | [**Vector2**](xref:Microsoft.Xna.Framework.Vector2) | Gets the delta information about the first touch-point in the gesture sample. | +| [**Delta2**](xref:Microsoft.Xna.Framework.Input.Touch.GestureSample.Delta2) | [**Vector2**](xref:Microsoft.Xna.Framework.Vector2) | Gets the delta information about the second touch-point in the gesture sample. | +| [**GestureType**](xref:Microsoft.Xna.Framework.Input.Touch.GestureSample.GestureType) | [**GestureType**](xref:Microsoft.Xna.Framework.Input.Touch.GestureType) | Gets the type of the gesture. | +| [**Position**](xref:Microsoft.Xna.Framework.Input.Touch.GestureSample.Position) | [**Vector2**](xref:Microsoft.Xna.Framework.Vector2) | Gets the position of the first touch-point in the gesture sample. | +| [**Position2**](xref:Microsoft.Xna.Framework.Input.Touch.GestureSample.Position2) | [**Vector2**](xref:Microsoft.Xna.Framework.Vector2) | Gets the position of the second touch-point in the gesture sample. | + +> [!NOTE] +> Gestures have two delta properties and two position properties. This is because some gestures require multiple touch inputs to perform, such as performing a pinch to zoom in or out. You would need the location of both touch points to determine the correct zoom to apply during the gesture. + +To determine what type of gesture is performed, we can get that from the [**GestureType**](xref:Microsoft.Xna.Framework.Input.Touch.GestureSample.GestureType) property which will be one of the following values: + +| Gesture Type | Description | +|----------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------| +| [**DoubleTap**](xref:Microsoft.Xna.Framework.Input.Touch.GestureType) | The user double tapped the device twice which is always preceded by a Tap gesture. | +| [**DragComplete**](xref:Microsoft.Xna.Framework.Input.Touch.GestureType) | States completion of a drag gesture (VerticalDrag, HorizontalDrag, or FreeDrag). | +| [**Flick**](xref:Microsoft.Xna.Framework.Input.Touch.GestureType) | States that a touch was combined with a quick swipe. | +| [**FreeDrag**](xref:Microsoft.Xna.Framework.Input.Touch.GestureType) | The user touched a point and the performed a free-form drag. | +| [**Hold**](xref:Microsoft.Xna.Framework.Input.Touch.GestureType) | The user touched a single point for approximately one second. | +| [**HorizontalDrag**](xref:Microsoft.Xna.Framework.Input.Touch.GestureType) | The user touched the screen and performed either a left-to-right or right-to-left drag gesture. | +| [**None**](xref:Microsoft.Xna.Framework.Input.Touch.GestureType) | No gesture. | +| [**Pinch**](xref:Microsoft.Xna.Framework.Input.Touch.GestureType) | The user converged or diverged two touch-points on the screen which is like a two-finger drag. | +| [**PinchComplete**](xref:Microsoft.Xna.Framework.Input.Touch.GestureType) | An in-progress pinch gesture was completed. | +| [**Tap**](xref:Microsoft.Xna.Framework.Input.Touch.GestureType) | The user touched a single point. | +| [**VerticalDrag**](xref:Microsoft.Xna.Framework.Input.Touch.GestureType) | The user touched the screen and performed either a top-to-bottom or bottom-to-top drag gesture. | + +> [!IMPORTANT] +> Before gestures can be detected, they have to be enabled using [**TouchPanel.EnabledGestures**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel.EnabledGestures). This can be done in [**Game.Initialize**](xref:Microsoft.Xna.Framework.Game.Initialize) like the following: +> +> [!code-csharp[](./snippets/enablegestures.cs)] + +The following is an example of using a gesture to detect horizontal and vertical drags: + +[!code-csharp[](./snippets/gestures.cs)] + +> [!IMPORTANT] +> Notice above that we use a `while` loop with [**TouchPanel.IsGestureAvailable**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel.IsGestureAvailable) as the condition for the loop. The reason we do this is because when a user performs a gesture, such as a horizontal drag across the screen, very quickly, what can often occurs is a series of multiple small drag gestures are registered and queued. +> +> Each time [**TouchPanel.ReadGesture**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel.ReadGesture) is called, it will dequeue the next gesture. So to ensure that we handle the complete gesture, we loop the gesture queue until there are none left. + +## Implementing Input in Our Game + +For our game, we are going to implement keyboard and gamepad controls based on the following criteria: + +| Keyboard Input | Gamepad Input | Description | +|---------------------------|---------------------------------------------|--------------------------------------| +| [Keys.W] and [Keys.Up] | [Thumbstick.Left.Y] and [Buttons.DPadUp] | Moves the slime up the screen. | +| [Keys.S] and [Keys.Down] | [Thumbstick.Left.Y] and [Buttons.DPadDown] | Moves the slime down the screen | +| [Keys.A] and [Keys.Left] | [Thumbstick.Left.X] and [Buttons.DPadLeft] | Moves the slime left on the screen. | +| [Keys.D] and [Keys.Right] | [Thumbstick.Left.X] and [Buttons.DPadRight] | Moves the slime right on the screen. | +| [Keys.Space] | [Buttons.A] | Increased the speed of the slime. | + +Open `Game1.cs` the *DungeonSlime* project and update it with the following: + +[!code-csharp[](./snippets/game1.cs?highlight=17-21,60-64,69-157,168)] + +The key changes made here are: + +1. The `_slimePosition` field was added to track the position of the slime as it moves. +2. The `MOVEMENT_SPEED` constant was added to use as the base multiplier for the movement speed. +3. The `CheckKeyboardInput` method was added which checks for input from the keyboard based on the input table above and moves the slime based on the keyboard input detected. +4. The `CheckGamePadInput` method was added which checks for input from the gamepad based on the input table above and moves the slime based the gamepad input detected. + + > [!NOTE] + > The gamepad implementation includes a priority system for directional input. The code prioritizes the analog thumbstick values over the digital DPad buttons. This design choice provides players with more nuanced control, as analog inputs allow for a variable movements speed based on how far the thumbstick is pushed, while DPad buttons only provide on/off input states. The code first checks if either thumbstick axis has a non-zero value, and only falls back to DPad input when the thumbstick is centered. + > + > To enhance player experience, the gamepad implementation also includes gamepad vibration when the speed boost is activated. Haptic feedback like this creates a more immersive experience by engaging additional senses for the player beyond just visual and auditory feedback. + +5. In [**Update**](xref:Microsoft.Xna.Framework.Game.Update(Microsoft.Xna.Framework.GameTime)) `CheckKeyboardInput` and `CheckGamePadInput` methods are called. +6. In [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)), the slime is now drawn using `_slimePosition` as the position. + +Running the game now, you can move the slime around using the keyboard with the arrow keys or WASD keys. If you have a gamepad plugged in you can also use the DPad and left thumbstick. + +| ![Figure 10-1: The slime moving around based on device input](./videos/input-moving-slime.webm) | +|:-----------------------------------------------------------------------------------------------:| +| **Figure 10-1: The slime moving around based on device input** | + +> [!NOTE] +> You may notice that the slime is capable of moving completely off the screen, this is completely normal as we have not yet implemented any logic to prevent it from doing so, it only doing what we currently tell it to do. + +## Input Buffering + +While checking for input every frame works well for continuous actions like movement, many games benefit from more sophisticated input handling techniques. One such technique is **input buffering**, which can significantly improve how responsive controls feel to players. + +### Understanding Input Buffering + +Input buffering is a technique where the game temporarily stores player inputs that cannot be immediately processed. Instead of discarding these inputs, they are placed in a queue and processed in order when the game is ready to handle them. + +Input buffering is particularly valuable in games where: + +- Actions occur at fixed intervals rather than continuously (like turn-based games or grid movement). +- Precise timing is required for complex input sequences (like fighting games). +- Multiple rapid inputs need to be remembered in order (like quick directional changes). + +Without input buffering, players must time their inputs perfectly to align with the game's update cycle. With buffering, the game becomes more forgiving and responsive by: + +1. Storing inputs that arrive between action updates. +2. Preserving the order of inputs for more predictable behavior. +3. Creating a sense that the game is actually listening to the player. + +### Implementing a Simple Input Buffer + +A basic input buffer can be implemented using a queue data structure, which follows a First-In-First-Out (FIFO) pattern: + +[!code-csharp[](./snippets/inputbuffer.cs)] + +> [!NOTE] +> The [`Queue`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.queue-1?view=net-9.0>) is a First In, First Out (FIFO) collection in C#. When you add items with `Enqueue()`, they join the end of the line, and when you retrieve items with `Dequeue()`, you always get the oldest item (the one at the front of the line). Think of it like people waiting in line - the first person to arrive is the first one served. +> +> This contrasts with a [`Stack`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.stack-1?view=net-9.0>), which follows Last In, First Out (LIFO) behavior, where the most recently added item is the first one retrieved. + +The size of an input buffer is an important design decision. If it is too small, players might still feel the game is not responsive enough. If it is too large, the game might feel like it is playing itself by working through a backlog of commands. + +### When to Use Input Buffering + +Consider implementing input buffering in your game when: + +- Players complain about the game feeling "unresponsive". +- Your game uses fixed-interval updates for certain mechanics. +- Actions require precise timing that is difficult for players to hit consistently. +- You want to allow players to "queue up" their next few moves. + +We will see a practical implementation of input buffering in [Chapter 23](../23_completing_the_game/index.md) when we finalize our snake-like game mechanics, where timing and direction changes are critical to gameplay. + +## Conclusion + +In this chapter, you accomplished the following: + +- Handle keyboard input to detect key presses. +- Handle mouse input including button clicks and cursor position. +- Work with gamepad controls including buttons, thumbsticks, and vibration. +- Understand touch input for mobile devices including touch points and gestures. +- Implement movement controls using different input methods. +- Consider controller-specific details like coordinate systems and analog vs digital input. + +In the next chapter, we will learn how to track previous input states to handle single-press events and implement an input management system to simplify some of the complexity of handling input. + +## Test Your Knowledge + +1. Why do we store the result of `GetState` in a variable instead of calling it multiple times? + + :::question-answer + Storing the state in a variable is more efficient and ensures consistent input checking within a frame. Each `GetState` call polls the device, which can impact performance if called repeatedly. + ::: + +2. What is the main difference between how keyboard and mouse/gamepad button states are checked? + + :::question-answer + Keyboard input uses [**IsKeyUp**](xref:Microsoft.Xna.Framework.Input.KeyboardState.IsKeyUp(Microsoft.Xna.Framework.Input.Keys))/[**IsKeyDown**](xref:Microsoft.Xna.Framework.Input.KeyboardState.IsKeyDown(Microsoft.Xna.Framework.Input.Keys)) methods, while mouse and gamepad buttons return a [**ButtonState**](xref:Microsoft.Xna.Framework.Input.ButtonState) enum value (Pressed or Released). + ::: + +3. When using thumbstick values for movement, why do we multiply the Y value by -1? + + :::question-answer + The thumbstick Y-axis values (-1.0f down to 1.0f up) are inverted compared to MonoGame's screen coordinate system (Y increases downward). Multiplying by -1 aligns the thumbstick direction with screen movement. + ::: + +4. What is the difference between analog and digital trigger input on a gamepad? + + :::question-answer + Analog triggers provide values between 0.0f and 1.0f based on how far they are pressed, while digital triggers only report 0.0f (not pressed) or 1.0f (pressed). This affects how you handle trigger input in your game. + ::: + +5. What is the key difference between [**TouchPanel.GetState**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel.GetState) and [**TouchPanel.ReadGesture**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel.ReadGesture)? + + :::question-answer + [**TouchPanel.GetState**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel.GetState) returns information about current touch points on the screen, while [**TouchPanel.ReadGesture**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel.ReadGesture) provides information about specific gesture patterns like taps, drags, and pinches that have been performed. + ::: + +6. Why do we use a while loop with [**TouchPanel.IsGestureAvailable**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel.IsGestureAvailable) when reading gestures? + + :::question-answer + Quick gestures can generate multiple gesture events that are queued. Using a while loop with [**TouchPanel.IsGestureAvailable**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel.IsGestureAvailable) ensures we process all queued gestures, as [**TouchPanel.ReadGesture**](xref:Microsoft.Xna.Framework.Input.Touch.TouchPanel.ReadGesture) only returns one gesture at a time. + ::: + +7. How does touch input differ from mouse input in terms of handling multiple input points? + + :::question-answer + Touch input can handle multiple simultaneous touch points through the [**TouchCollection**](xref:Microsoft.Xna.Framework.Input.Touch.TouchCollection), while mouse input only tracks a single cursor position. This allows touch input to support features like multi-touch gestures that are not possible with a mouse. + ::: + +8. What are the different states a [**TouchLocation**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation) can have and what do they indicate? + + :::question-answer + A [**TouchLocation**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocation) can have four states: + + - [**Pressed**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocationState): Initial contact with the screen + - [**Moved**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocationState): Touch point moved while maintaining contact + - [**Released**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocationState): Contact with the screen ended + - [**Invalid**](xref:Microsoft.Xna.Framework.Input.Touch.TouchLocationState): Touch data is not valid or tracking was lost + + ::: diff --git a/articles/tutorials/building_2d_games/10_handling_input/snippets/buttonstate.cs b/articles/tutorials/building_2d_games/10_handling_input/snippets/buttonstate.cs new file mode 100644 index 00000000..c68d5132 --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/snippets/buttonstate.cs @@ -0,0 +1,8 @@ +// Get the current state of player one's gamepad +GamePadState gamePadState = GamePad.GetState(PlayerIndex.One); + +// Check if the down on the DPad is pressed. +if(gamePadState.DPad.Down == ButtonState.Pressed) +{ + // DPad down is pressed, do something. +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/10_handling_input/snippets/enablegestures.cs b/articles/tutorials/building_2d_games/10_handling_input/snippets/enablegestures.cs new file mode 100644 index 00000000..75b25268 --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/snippets/enablegestures.cs @@ -0,0 +1,10 @@ +protected override void Initialize() +{ + base.Initialize(); + + // Enable gestures we want to handle + TouchPanel.EnabledGestures = + GestureType.Tap | + GestureType.HorizontalDrag | + GestureType.VerticalDrag; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/10_handling_input/snippets/game1.cs b/articles/tutorials/building_2d_games/10_handling_input/snippets/game1.cs new file mode 100644 index 00000000..a48e5d3d --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/snippets/game1.cs @@ -0,0 +1,178 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; + +namespace DungeonSlime; + +public class Game1 : Core +{ + // Defines the slime animated sprite. + private AnimatedSprite _slime; + + // Defines the bat animated sprite. + private AnimatedSprite _bat; + + // Tracks the position of the slime. + private Vector2 _slimePosition; + + // Speed multiplier when moving. + private const float MOVEMENT_SPEED = 5.0f; + + public Game1() : base("Dungeon Slime", 1280, 720, false) + { + + } + + protected override void Initialize() + { + // TODO: Add your initialization logic here + + base.Initialize(); + } + + protected override void LoadContent() + { + // Create the texture atlas from the XML configuration file + TextureAtlas atlas = TextureAtlas.FromFile(Content, "images/atlas-definition.xml"); + + // Create the slime animated sprite from the atlas. + _slime = atlas.CreateAnimatedSprite("slime-animation"); + _slime.Scale = new Vector2(4.0f, 4.0f); + + // Create the bat animated sprite from the atlas. + _bat = atlas.CreateAnimatedSprite("bat-animation"); + _bat.Scale = new Vector2(4.0f, 4.0f); + } + + protected override void Update(GameTime gameTime) + { + if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape)) + Exit(); + + // Update the slime animated sprite. + _slime.Update(gameTime); + + // Update the bat animated sprite. + _bat.Update(gameTime); + + // Check for keyboard input and handle it. + CheckKeyboardInput(); + + // Check for gamepad input and handle it. + CheckGamePadInput(); + + base.Update(gameTime); + } + + private void CheckKeyboardInput() + { + // Get the state of keyboard input + KeyboardState keyboardState = Keyboard.GetState(); + + // If the space key is held down, the movement speed increases by 1.5 + float speed = MOVEMENT_SPEED; + if (keyboardState.IsKeyDown(Keys.Space)) + { + speed *= 1.5f; + } + + // If the W or Up keys are down, move the slime up on the screen. + if (keyboardState.IsKeyDown(Keys.W) || keyboardState.IsKeyDown(Keys.Up)) + { + _slimePosition.Y -= speed; + } + + // if the S or Down keys are down, move the slime down on the screen. + if (keyboardState.IsKeyDown(Keys.S) || keyboardState.IsKeyDown(Keys.Down)) + { + _slimePosition.Y += speed; + } + + // If the A or Left keys are down, move the slime left on the screen. + if (keyboardState.IsKeyDown(Keys.A) || keyboardState.IsKeyDown(Keys.Left)) + { + _slimePosition.X -= speed; + } + + // If the D or Right keys are down, move the slime right on the screen. + if (keyboardState.IsKeyDown(Keys.D) || keyboardState.IsKeyDown(Keys.Right)) + { + _slimePosition.X += speed; + } + } + + private void CheckGamePadInput() + { + GamePadState gamePadState = GamePad.GetState(PlayerIndex.One); + + // If the A button is held down, the movement speed increases by 1.5 + // and the gamepad vibrates as feedback to the player. + float speed = MOVEMENT_SPEED; + if (gamePadState.IsButtonDown(Buttons.A)) + { + speed *= 1.5f; + GamePad.SetVibration(PlayerIndex.One, 1.0f, 1.0f); + } + else + { + GamePad.SetVibration(PlayerIndex.One, 0.0f, 0.0f); + } + + // Check thumbstick first since it has priority over which gamepad input + // is movement. It has priority since the thumbstick values provide a + // more granular analog value that can be used for movement. + if (gamePadState.ThumbSticks.Left != Vector2.Zero) + { + _slimePosition.X += gamePadState.ThumbSticks.Left.X * speed; + _slimePosition.Y -= gamePadState.ThumbSticks.Left.Y * speed; + } + else + { + // If DPadUp is down, move the slime up on the screen. + if (gamePadState.IsButtonDown(Buttons.DPadUp)) + { + _slimePosition.Y -= speed; + } + + // If DPadDown is down, move the slime down on the screen. + if (gamePadState.IsButtonDown(Buttons.DPadDown)) + { + _slimePosition.Y += speed; + } + + // If DPapLeft is down, move the slime left on the screen. + if (gamePadState.IsButtonDown(Buttons.DPadLeft)) + { + _slimePosition.X -= speed; + } + + // If DPadRight is down, move the slime right on the screen. + if (gamePadState.IsButtonDown(Buttons.DPadRight)) + { + _slimePosition.X += speed; + } + } + } + + protected override void Draw(GameTime gameTime) + { + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + // Draw the slime sprite. + _slime.Draw(SpriteBatch, _slimePosition); + + // Draw the bat sprite 10px to the right of the slime. + _bat.Draw(SpriteBatch, new Vector2(_slime.Width + 10, 0)); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/10_handling_input/snippets/gamepadstate.cs b/articles/tutorials/building_2d_games/10_handling_input/snippets/gamepadstate.cs new file mode 100644 index 00000000..ab65dc08 --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/snippets/gamepadstate.cs @@ -0,0 +1,8 @@ +// Get the current state of player one's gamepad +GamePadState gamePadState = GamePad.GetState(PlayerIndex.One); + +// Check if the A button is pressed down. +if(gamePadState.Buttons.A == ButtonState.Pressed) +{ + // Button A is pressed, do something. +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/10_handling_input/snippets/gestures.cs b/articles/tutorials/building_2d_games/10_handling_input/snippets/gestures.cs new file mode 100644 index 00000000..3c8b125d --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/snippets/gestures.cs @@ -0,0 +1,24 @@ +while(TouchPanel.IsGestureAvailable) +{ + GestureSample gesture = TouchPanel.ReadGesture(); + + if(gesture.GestureType == GestureType.HorizontalDrag) + { + // A horizontal drag from left-to-right or right-to-left occurred. + // You can use the Delta property to determine how much movement + // occurred during the swipe. + float xDragAmount = gesture.Delta.X; + + // Now do something with that information. + } + + if(gesture.GestureType == GestureType.VerticalDrag) + { + // A vertical drag from top-to-bottom or bottom-to-top occurred. + // You can use the Delta property to determine how much movement + // occurred during the swipe. + float yDragAmount = gesture.Delta.Y; + + // Now do something with that information. + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/10_handling_input/snippets/inputbuffer.cs b/articles/tutorials/building_2d_games/10_handling_input/snippets/inputbuffer.cs new file mode 100644 index 00000000..dec0fa1f --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/snippets/inputbuffer.cs @@ -0,0 +1,40 @@ +// Use a queue directly for input buffering +private Queue _inputBuffer; +private const int MAX_BUFFER_SIZE = 2; + +// In initialization code: +_inputBuffer = new Queue(MAX_BUFFER_SIZE); + +// In the input handling code: +KeyboardState keyboard = Keyboard.GetState(); +Vector2 newDirection = Vector2.Zero; + +if(keyboard.IsKeyDown(Keys.Up)) +{ + newDirection = -Vector2.UnitY; +} +else if(keyboard.IsKeyDown(Keys.Down)) +{ + newDirection = Vector2.UnitY; +} +else if(keyboard.IsKeyDown(Keys.Left)) +{ + newDirection = -Vector2.UnitX; +} +else if(keyboard.IsKeyDown(Keys.Right)) +{ + newDirection = Vector2.UnitX; +} + +// Only add if a valid direction and does not exceed the buffer size +if(newDirection != Vector2.Zero && _inputBuffer.Count < MAX_BUFFER_SIZE) +{ + _inputBuffer.Enqueue(newDirection); +} + +// In movement update code +if(_inputBuffer.COunt > 0) +{ + Vector2 nextDirection = _inputBuffer.Dequeue(); + _position += nextDirection * _speed; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/10_handling_input/snippets/isbuttondown.cs b/articles/tutorials/building_2d_games/10_handling_input/snippets/isbuttondown.cs new file mode 100644 index 00000000..85526ae6 --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/snippets/isbuttondown.cs @@ -0,0 +1,8 @@ +// Get the current state of player one's gamepad +GamePadState gamePadState = GamePad.GetState(PlayerIndex.One); + +// Check if the A button is down. +if(gamePadState.IsButtonDown(Buttons.A)) +{ + // The A button is pressed, do something. +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/10_handling_input/snippets/keyboardstate.cs b/articles/tutorials/building_2d_games/10_handling_input/snippets/keyboardstate.cs new file mode 100644 index 00000000..7243305a --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/snippets/keyboardstate.cs @@ -0,0 +1,8 @@ +// Get the current state of keyboard input. +KeyboardState keyboardState = Keyboard.GetState(); + +// Check if the space key is down. +if(keyboardState.IsKeyDown(Keys.Space)) +{ + // The space key is down, so do something. +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/10_handling_input/snippets/mousestate.cs b/articles/tutorials/building_2d_games/10_handling_input/snippets/mousestate.cs new file mode 100644 index 00000000..e794f95a --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/snippets/mousestate.cs @@ -0,0 +1,8 @@ +// Get the current state of mouse input. +MouseState mouseState = Mouse.GetState(); + +// Check if the left mouse button is pressed down. +if(mouseState.LeftButton == ButtonState.Pressed) +{ + // The left button is down, so do something. +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/10_handling_input/snippets/thumbstick.cs b/articles/tutorials/building_2d_games/10_handling_input/snippets/thumbstick.cs new file mode 100644 index 00000000..d2525cac --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/snippets/thumbstick.cs @@ -0,0 +1,11 @@ +// Get the current state of player one's gamepad +GamePadState gamePadState = GamePad.GetState(PlayerIndex.One); + +// Get the value of the left thumbstick. +Vector2 leftStick = gamePadState.Thumbsticks.Left; + +// Invert the y-axis value +leftStick.Y *= -1.0f; + +// Apply the value to the position of the sprite. +sprite.Position += leftStick; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/10_handling_input/snippets/touchstate.cs b/articles/tutorials/building_2d_games/10_handling_input/snippets/touchstate.cs new file mode 100644 index 00000000..d0cb223b --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/snippets/touchstate.cs @@ -0,0 +1,11 @@ +// Get the current state of touch input. +TouchCollection touchCollection = TouchPanel.GetState(); + +foreach(TouchLocation touchLocation in touchCollection) +{ + if(touchLocation.State == TouchLocationState.Pressed || touchLocation.State == TouchLocationState.Moved) + { + // The the location at touchLocation.Position is currently being pressed, + // so we can act on that information. + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/10_handling_input/snippets/triggers.cs b/articles/tutorials/building_2d_games/10_handling_input/snippets/triggers.cs new file mode 100644 index 00000000..fdde9d06 --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/snippets/triggers.cs @@ -0,0 +1,5 @@ +// Get the current state of player one's gamepad +GamePadState gamePadState = GamePad.GetState(PlayerIndex.One); + +// Get the acceleration based on how far the right trigger is pushed down. +float acceleration = gamePadState.Triggers.Right; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/10_handling_input/snippets/vibration.cs b/articles/tutorials/building_2d_games/10_handling_input/snippets/vibration.cs new file mode 100644 index 00000000..06805dec --- /dev/null +++ b/articles/tutorials/building_2d_games/10_handling_input/snippets/vibration.cs @@ -0,0 +1,8 @@ +// Make the gamepad vibrate at full intensity +GamePad.SetVibration(PlayerIndex.One, 1.0f, 1.0f); + +// Stop all vibration +GamePad.SetVibration(PlayerIndex.One, 0.0f, 0.0f); + +// Create a subtle, low-intensity vibration +GamePad.SetVibration(PlayerIndex.One, 0.3f, 0.1f); \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/10_handling_input/videos/input-moving-slime.webm b/articles/tutorials/building_2d_games/10_handling_input/videos/input-moving-slime.webm new file mode 100644 index 00000000..a4d21c4a Binary files /dev/null and b/articles/tutorials/building_2d_games/10_handling_input/videos/input-moving-slime.webm differ diff --git a/articles/tutorials/building_2d_games/11_input_management/index.md b/articles/tutorials/building_2d_games/11_input_management/index.md new file mode 100644 index 00000000..84f3f590 --- /dev/null +++ b/articles/tutorials/building_2d_games/11_input_management/index.md @@ -0,0 +1,429 @@ +--- +title: "Chapter 11: Input Management" +description: "Learn how to create an input management system to handle keyboard, mouse, and gamepad input, including state tracking between frames and creating a reusable framework for handling player input." +--- + +In [Chapter 10](../10_handling_input/index.md), you learned how to handle input from various devices like keyboard, mouse, and gamepad. While checking if an input is currently down works well for continuous actions like movement, many game actions should only happen once when an input is first pressed; think firing a weapon or jumping. To handle these scenarios, we need to compare the current input state with the previous frame's state to detect when an input changes from up to down. + +In this chapter you will: + +- Learn the difference between an input being down versus being pressed +- Track input states between frames +- Create a reusable input management system +- Simplify handling input across multiple devices + +We will start by understanding the concept of input state changes and how we can detect them. + +## Understanding Input States + +When handling input in games, there are two key scenarios we need to consider: + +- An input is being held down (like holding a movement key). +- An input was just pressed for one frame (like pressing a jump button). + +Now, we will look at the difference using keyboard input as an example. With our current implementation, we can check if a key is down using [**KeyboardState.IsKeyDown**](xref:Microsoft.Xna.Framework.Input.KeyboardState.IsKeyDown(Microsoft.Xna.Framework.Input.Keys)): + +[!code-csharp[](./snippets/key_down_every_frame.cs)] + +However, many game actions should not repeat while a key is held. For instance, if the Space key makes your character jump, you probably do not want them to jump repeatedly just because the player is holding the key down. Instead, you want the jump to happen only on the first frame when Space is pressed. + +To detect this "just pressed" state, we need to compare two states: + +1. Is the key down in the current frame? +2. Was the key up in the previous frame? + +If both conditions are true, we know the key was just pressed. If we were to modify the above code to track the previous keyboard state it would look something like this: + +[!code-csharp[](./snippets/compare_previous_state.cs)] + +If you need to know the inverse state, when the key was just released, then it is simply a matter of swiching the checking of the states, for example, is the key up this frame and was it down in the previous frame. + +This same concept applies to mouse buttons and gamepad input as well. Any time you need to detect a "just pressed" or "just released" state, you will need to compare the current input state with the previous frame's state. + +So far, we have only been working with our game within the `Game1.cs` file. This has been fine for the examples given. Overtime, as the game grows, we are going to have a more complex system setup with different scenes, and each scene will need a way to track the state of input over time. We could do this by creating a lot of variables in each scene to track this information, or we can use object-oriented design concepts to create a reusable `InputManager` class to simplify this for us. + +Before we create the `InputManager` class, we should first create classes for the keyboard, mouse, and gamepad that encapsulates the information about those inputs which will then be exposed through the `InputManager`. + +To get started, create a new folder called `Input` in the *MonoGameLibrary* project. We will put all of our input related classes here. + +## The KeyboardInfo Class + +We will start our input management system by creating a class to handle keyboard input. The `KeyboardInfo` class will encapsulate all keyboard-related functionality, making it easier to: + +- Track current and previous keyboard states +- Detect when keys are pressed or released +- Check if keys are being held down + +In the `Input` folder of the *MonoGameLibrary* project, add a new file named `KeyboardInfo.cs` with this initial structure: + +[!code-csharp[](./snippets/keyboardinfo.cs#declaration)] + +### KeyboardInfo Properties + +To detect changes in keyboard input between frames, we need to track both the previous and current keyboard states. Add these properties to the `KeyboardInfo` class: + +[!code-csharp[](./snippets/keyboardinfo.cs#members)] + +> [!NOTE] +> These properties use a public getter but private setter pattern. This allows other parts of the game to read the keyboard states if needed, while ensuring only the `KeyboardInfo` class can update them. + +### KeyboardInfo Constructor + +The `KeyboardInfo` class constructor needs to initialize the keyboard states. + +Add this constructor: + +[!code-csharp[](./snippets/keyboardinfo.cs#ctors)] + +The constructor: + +- Creates an empty state for `PreviousState` since there is no previous input yet +- Gets the current keyboard state as our starting point for `CurrentState` + +This initialization ensures we have valid states to compare against in the first frame of our game, preventing any potential null reference issues when checking for input changes. + +### KeyboardInfo Methods + +The `KeyboardInfo` class needs methods both for updating states and checking key states. First, we will start with our update method: + +[!code-csharp[](./snippets/keyboardinfo.cs#methods_update)] + +> [!NOTE] +> Each time `Update` is called, the current state becomes the previous state, and we get a fresh current state. This creates our frame-to-frame comparison chain. + +Next, we will add methods to check various key states: + +[!code-csharp[](./snippets/keyboardinfo.cs#methods_keystate)] + +These methods serve two distinct purposes. For checking continuous states: + +- `IsKeyDown`: Returns true as long as the specified key is being held down. +- `IsKeyUp`: Returns true as long as the specified key is not being pressed. + +And for detecting state changes: + +- `WasKeyJustPressed`: Returns true only on the frame when the specified key changes from up-to-down. +- `WasKeyJustReleased`: Returns true only on the frame when the specified key changes from down-to-up. + +> [!TIP] +> Use continuous state checks (`IsKeyDown`/`IsKeyUp`) for actions that should repeat while a key is held, like movement. Use single-frame checks (`WasKeyJustPressed`/`WasKeyJustReleased`) for actions that should happen once per key press, like jumping or shooting. + +That's it for the `KeyboardInfo` class, now we can move on to mouse input next. + +## MouseButton Enum + +Recall from the [Mouse Input](../10_handling_input/index.md#mouse-input) section of the previous chapter that the [**MouseState**](xref:Microsoft.Xna.Framework.Input.MouseState) struct provides button states through properties rather than methods like `IsButtonDown`/`IsButtonUp`. To keep our input management API consistent across devices, we will create a `MouseButton` enum that lets us reference mouse buttons in a similar way to how we use [**Keys**](xref:Microsoft.Xna.Framework.Input.Keys) for keyboard input and [**Buttons**](xref:Microsoft.Xna.Framework.Input.Buttons) for gamepad input. + +In the `Input` folder of the *MonoGameLibrary* project, add a new file named `MouseButton.cs` with the following code: + +[!code-csharp[](./snippets/mousebutton.cs)] + +> [!NOTE] +> Each enum value corresponds directly to a button property in MouseState: +> +> - `Left`: Maps to [**MouseState.LeftButton**](xref:Microsoft.Xna.Framework.Input.MouseState.LeftButton). +> - `Middle`: Maps to [**MouseState.MiddleButton**](xref:Microsoft.Xna.Framework.Input.MouseState.MiddleButton). +> - `Right`: Maps to [**MouseState.RightButton**](xref:Microsoft.Xna.Framework.Input.MouseState.RightButton). +> - `XButton1`: Maps to [**MouseState.XButton1**](xref:Microsoft.Xna.Framework.Input.MouseState.XButton1). +> - `XButton2`: Maps to [**MouseState.XButton2**](xref:Microsoft.Xna.Framework.Input.MouseState.XButton2). + +## The MouseInfo Class + +To manage mouse input effectively, we need to track both current and previous states, as well as provide easy access to mouse position, scroll wheel values, and button states. The `MouseInfo` class will encapsulate all of this functionality, making it easier to: + +- Track current and previous mouse states. +- Track the mouse position. +- Check the change in mouse position between frames and if it was moved. +- Track scroll wheel changes. +- Detect when mouse buttons are pressed or released +- Check if mouse buttons are being held down + +To get started, in the `Input` folder of the *MonoGameLibrary* project, create a new file named `MouseInfo.cs` with the following initial structure: + +[!code-csharp[](./snippets/mouseinfo.cs#declaration)] + +### MouseInfo Properties + +The `MouseInfo` class needs properties to track both mouse states and provide easy access to common mouse information. Add the following properties to the `MouseInfo` class: + +First, we need properties for tracking mouse states: + +[!code-csharp[](./snippets/mouseinfo.cs#properties_states)] + +Next, we will add properties for handling cursor position: + +[!code-csharp[](./snippets/mouseinfo.cs#properties_position)] + +> [!NOTE] +> The position properties use a `SetPosition` method that we will implement later. This method will handle the actual cursor positioning on screen. + +These properties provide different ways to work with the cursor position: + +- `Position`: Gets/sets the cursor position as a [**Point**](xref:Microsoft.Xna.Framework.Point). +- `X`: Gets/sets just the horizontal position. +- `Y`: Gets/sets just the vertical position. + +Next, we will add properties for determining if the mouse cursor moved between game frames and if so how much: + +[!code-csharp[](./snippets/mouseinfo.cs#properties_position_delta)] + +The properties provide different ways of detecting mouse movement between frames: + +- `PositionDelta`: Gets how much the cursor moved between frames as a [**Point**](xref:Microsoft.Xna.Framework.Point). +- `XDelta`: Gets how much the cursor moved horizontally between frames. +- `YDelta`: Gets how much the cursor moved vertically between frames. +- `WasMoved`: Indicates if the cursor moved between frames. + +Finally, we will add properties for handling the scroll wheel: + +[!code-csharp[](./snippets/mouseinfo.cs#properties_scrollwheel)] + +The scroll wheel properties serve different purposes: + +- `ScrollWheel`: Gets the total accumulated scroll value since game start. +- `ScrollWheelDelta`: Gets the change in scroll value just in this frame. + +> [!TIP] +> Use `ScrollWheelDelta` when you need to respond to how much the user just scrolled, rather than tracking the total scroll amount. + +### MouseInfo Constructor + +The `MouseInfo` class constructor needs to initialize the mouse states. + +Add this constructor: + +[!code-csharp[](./snippets/mouseinfo.cs#ctors)] + +The constructor: + +- Creates an empty state for `PreviousState` since there is no previous input yet. +- Gets the current mouse state as our starting point for `CurrentState`. + +This initialization ensures we have valid states to compare against in the first frame of our game, preventing any potential null reference issues when checking for input changes. + +### MouseInfo Methods + +The `MouseInfo` class needs methods for updating states, checking button states, and setting the cursor position. Add the following method to the `MouseInfo` class: + +[!code-csharp[](./snippets/mouseinfo.cs#methods_update)] + +Next, we will add methods to check various button states: + +[!code-csharp[](./snippets/mouseinfo.cs#methods_buttonstate)] + +These methods serve two distinct purposes. For checking continuous states: + +- `IsButtonDown`: Returns true as long as the specified button is being held down. +- `IsButtonUp`: Returns true as long as the specified button is not being pressed. + +And for detecting state changes: + +- `WasButtonJustPressed`: Returns true only on the frame when the specified button changes from up-to-down. +- `WasButtonJustReleased`: Returns true only on the frame when the specified button changes from down-to-up. + +> [!NOTE] +> Each method uses a switch statement to check the appropriate button property from the [**MouseState**](xref:Microsoft.Xna.Framework.Input.MouseState) based on which `MouseButton` enum value is provided. This provides a consistent API while handling the different button properties internally. + +Finally, we need a method to handle setting the cursor position: + +[!code-csharp[](./snippets/mouseinfo.cs#methods_setposition)] + +> [!TIP] +> Notice that after setting the position, we immediately update the `CurrentState`. This ensures our state tracking remains accurate even when manually moving the cursor. + +That's it for the `MouseInfo` class, next we will move onto gamepad input. + +## The GamePadInfo Class + +To manage gamepad input effectively, we need to track both current and previous states, is the gamepad still connected, as well as provide easy access to the thumbstick values, trigger values, and button states. The `GamePadInfo` class will encapsulate all of this functionality, making it easier to: + +- Track current and previous gamepad states. +- Check if the gamepad is still connected. +- Track the position of the left and right thumbsticks. +- Check the values of the left and right triggers. +- Detect when gamepad buttons are pressed or released. +- Check if gamepad buttons are being held down. +- Start and Stop vibration of a gamepad. + +To get started, in the `Input` folder f the *MonoGameLibrary* project, create a new file name `GamePadInfo.cs` with the following initial structure: + +[!code-csharp[](./snippets/gamepadinfo.cs#declaration)] + +### GamePadInfo Properties + +We use vibration in gamepads to provide haptic feedback to the player. The [**GamePad**](xref:Microsoft.Xna.Framework.Input.GamePad) class provides the [**SetVibration**](xref:Microsoft.Xna.Framework.Input.GamePad.SetVibration(Microsoft.Xna.Framework.PlayerIndex,System.Single,System.Single)) method to tell the gamepad to vibrate, but it does not provide a timing mechanism for it if we wanted to only vibrate for a certain period of time. Add the following private field to the `GamePadInfo` class: + +[!code-csharp[](./snippets/gamepadinfo.cs#member_fields)] + +If you recall from the [previous chapter](../10_handling_input/index.md#gamepad-input), a [**PlayerIndex**](xref:Microsoft.Xna.Framework.PlayerIndex) value needs to be supplied when calling [**Gamepad.GetState**](xref:Microsoft.Xna.Framework.Input.GamePad.GetState(Microsoft.Xna.Framework.PlayerIndex)). Doing this returns the state of the gamepad connected at that player index. So we will need a property to track the player index this gamepad info is for. + +[!code-csharp[](./snippets/gamepadinfo.cs#properties_playerindex)] + +To detect changes in the gamepad input between frames, we need to track both the previous and current gamepad states. Add these properties to the `GamePadInfo` class: + +[!code-csharp[](./snippets/gamepadinfo.cs#properties_state)] + +There are times that a gamepad can disconnect for various reasons; being unplugged, bluetooth disconnection, or battery dying are just some examples. To track if the gamepad is connected, add the following property: + +[!code-csharp[](./snippets/gamepadinfo.cs#properties_connected)] + +The values of the thumbsticks and triggers can be accessed through the `CurrentState`. However, instead of having to navigate through multiple property chains to get this information, add the following properties to get direct access to the values: + +[!code-csharp[](./snippets/gamepadinfo.cs#properties_thumbsticks_triggers)] + +### GamePadInfo Constructor + +The `GamePadInfo` class constructor needs to initialize the gamepad states. + +Add this constructor: + +[!code-csharp[](./snippets/gamepadinfo.cs#ctors)] + +This constructor + +- Requires a [**PlayerIndex**](xref:Microsoft.Xna.Framework.PlayerIndex) value which is stored and will be used to get the states for the correct gamepad +- Creates an empty state for `PreviousState` since there is no previous state yet. +- Gets the current gamepad state as our starting `CurrentState`. + +This initialization ensures we have valid states to compare against in the first frame of our game, preventing any potential null reference issues when checking for input changes. + +### GamePadInfo Methods + +The `GamePadInfo` class needs methods for updating states, checking button states, and controlling vibration. Add the following method to the `GamePadInfo` class: + +[!code-csharp[](./snippets/gamepadinfo.cs#methods_update)] + +> [!NOTE] +> Unlike keyboard and mouse input, the gamepad update method takes a [**GameTime**](xref:Microsoft.Xna.Framework.GameTime) parameter. This allows us to track and manage timed vibration effects. + +Next, we will add methods to check various button states: + +[!code-csharp[](./snippets/gamepadinfo.cs#methods_buttonstate)] + +These methods serve two distinct purposes. For checking continuous states: + +- `IsButtonDown`: Returns true as long as a button is being held down. +- `IsButtonUp`: Returns true as long as a button is not being pressed. + +And for detecting state changes: + +- `WasButtonJustPressed`: Returns true only on the frame when a button changes from up-to-down. +- `WasButtonJustReleased`: Returns true only on the frame when a button changes from down-to-up. + +Finally, we will add methods for controlling gamepad vibration: + +[!code-csharp[](./snippets/gamepadinfo.cs#methods_vibration)] + +The vibration methods provide control over the gamepad's haptic feedback: + +- `SetVibration`: Starts vibration at the specified strength for a set duration. +- `StopVibration`: Immediately stops all vibration. + +> [!TIP] +> When setting vibration, you can specify both the strength (`0.0f` to `1.0f`) and duration. The vibration will automatically stop after the specified time has elapsed, so you do not need to manage stopping it manually. + +That's it for the `GamePadInfo` class. Next, we can create the actual input manager. + +## The InputManager Class + +Now that we have classes to handle keyboard, mouse, and gamepad input individually, we can create a centralized manager class to coordinate all input handling. + +In the `Input` folder of the *MonoGameLibrary* project, add a new file named `InputManager.cs` with this initial structure: + +[!code-csharp[](./snippets/inputmanager.cs#declaration)] + +### InputManager Properties + +The `InputManager` class needs properties to access each type of input device. Add these properties: + +[!code-csharp[](./snippets/inputmanager.cs#properties)] + +> [!NOTE] +> The `GamePads` property is an array because MonoGame supports up to four gamepads simultaneously. Each gamepad is associated with a PlayerIndex (0-3). + +### InputManager Constructor + +The constructor for the `InputManager` initializes the keybaord, mouse, and gamepad states. + +Add the following constructor: + +[!code-csharp[](./snippets/inputmanager.cs#ctors)] + +### InputManager Methods + +The `Update` method for the `InputManager` calls update for each device so that they can update their internal states. + +[!code-csharp[](./snippets/inputmanager.cs#methods)] + +## Implementing the InputManager Class + +Now that we have our input management system complete, we will update our game to use it. We will do this in two steps: + +1. First, update the `Core` class to add the `InputManager` globally. +2. Update the `Game1` class to use the global input manager from `Core`. + +### Updating the Core Class + +The `Core` class serves as our base game class, so we will update it to add and expose the `InputManager` globally. Open the *Core.cs* file in the *MonoGameLibrary* project and update it to the following: + +[!code-csharp[](./snippets/core.cs?highlight=5-6,39-47,103-104,107-118)] + +The key changes to the `Core` class are: + +1. Added the `using MonoGameLibrary.Input;` directive to access the `InputManager` class. +2. Added a static `Input` property to provide global access to the input manager. +3. Added a static `ExitOnEscape` property to set whether the game should exit when the Escape key on the keyboard is pressed. +4. In `Initialize` the input manager is created. +5. Added an override for the `Update` method where: + 1. The input manager is updated + 2. A check is made to see if `ExitOnEscape` is true and if the Escape keyboard key is pressed. + +### Updating the Game1 Class + +Now we can update our `Game1` class to use the new input management system through the `Core` class. Open `Game1.cs` in the game project and update it to the following: + +[!code-csharp[](./snippets/game1.cs?highlight=6,74,80,86,92,98,106,111,114,118,124,126-127,132,138,144,150)] + +The key changes to the `Game1` class are: + +1. In [**Update**](xref:Microsoft.Xna.Framework.Game.Update(Microsoft.Xna.Framework.GameTime)), the check for the gamepad back button or keyboard escape key being pressed was removed. This is now handled by the `ExitOnEscape` property and the `Update` method of the `Core` class. +2. In `CheckKeyboardInput` and `CheckGamepadInput`, instead of getting the keyboard and gamepad states and then using the states, calls to check those devices are now done through the input. + +Running the game now, you will be able to control it the same as before, only now we are using our new `InputManager` class instead. + +| ![Figure 11-1: The slime moving around based on device input](./videos/input-moving-slime.webm) | +|:-----------------------------------------------------------------------------------------------:| +| **Figure 11-1: The slime moving around based on device input** | + +## Conclusion + +In this chapter, you accomplished the following: + +- Detect the difference between continuous and single-frame input states. +- Create classes to manage different input devices. +- Build a centralized `InputManager` to coordinate all input handling that is: + - Reusable across different game projects + - Easy to maintain and extend + - Consistent across different input devices +- Integrate the input system into the `Core` class for global access. +- Update the game to use the new input management system. + +## Test Your Knowledge + +1. What is the difference between checking if an input is "down" versus checking if it was "just pressed"? + + :::question-answer + "Down" checks if an input is currently being held, returning true every frame while held. "Just pressed" only returns true on the first frame when the input changes from up to down, requiring comparison between current and previous states. + ::: + +2. Why do we track both current and previous input states? + + :::question-answer + Tracking both states allows us to detect when input changes occur by comparing the current frame's state with the previous frame's state. This is essential for implementing "just pressed" and "just released" checks. + ::: + +3. What advantage does the `InputManager` provide over handling input directly? + + :::question-answer + The `InputManager` centralizes all input handling, automatically tracks states between frames, and provides a consistent API across different input devices. This makes the code more organized, reusable, and easier to maintain. + ::: diff --git a/articles/tutorials/building_2d_games/11_input_management/snippets/compare_previous_state.cs b/articles/tutorials/building_2d_games/11_input_management/snippets/compare_previous_state.cs new file mode 100644 index 00000000..9d947e75 --- /dev/null +++ b/articles/tutorials/building_2d_games/11_input_management/snippets/compare_previous_state.cs @@ -0,0 +1,21 @@ +// Track the state of keyboard input during the previous frame. +private KeyboardState _previousKeyboardState; + +protected override void Update(GameTime gameTime) +{ + // Get the current state of keyboard input. + KeyboardState keyboardState = Keyboard.GetState(); + + // Compare if the space key is down on the current frame but was up on the previous frame. + if (keyboardState.IsKeyDown(Keys.Space) && _previousKeyboardState.IsKeyUp(Keys.Space)) + { + // This will only run on the first frame Space is pressed and will not + // happen again until it has been released and then pressed again. + } + + // At the end of update, store the current state of keyboard input into the + // previous state tracker. + _previousKeyboardState = keyboardState; + + base.Update(gameTime); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/11_input_management/snippets/core.cs b/articles/tutorials/building_2d_games/11_input_management/snippets/core.cs new file mode 100644 index 00000000..21457ac5 --- /dev/null +++ b/articles/tutorials/building_2d_games/11_input_management/snippets/core.cs @@ -0,0 +1,119 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary.Input; + +namespace MonoGameLibrary; + +public class Core : Game +{ + internal static Core s_instance; + + /// + /// Gets a reference to the Core instance. + /// + public static Core Instance => s_instance; + + /// + /// Gets the graphics device manager to control the presentation of graphics. + /// + public static GraphicsDeviceManager Graphics { get; private set; } + + /// + /// Gets the graphics device used to create graphical resources and perform primitive rendering. + /// + public static new GraphicsDevice GraphicsDevice { get; private set; } + + /// + /// Gets the sprite batch used for all 2D rendering. + /// + public static SpriteBatch SpriteBatch { get; private set; } + + /// + /// Gets the content manager used to load global assets. + /// + public static new ContentManager Content { get; private set; } + + /// + /// Gets a reference to the input management system. + /// + public static InputManager Input { get; private set; } + + /// + /// Gets or Sets a value that indicates if the game should exit when the esc key on the keyboard is pressed. + /// + public static bool ExitOnEscape { get; set; } + + /// + /// Creates a new Core instance. + /// + /// The title to display in the title bar of the game window. + /// The initial width, in pixels, of the game window. + /// The initial height, in pixels, of the game window. + /// Indicates if the game should start in fullscreen mode. + public Core(string title, int width, int height, bool fullScreen) + { + // Ensure that multiple cores are not created. + if (s_instance != null) + { + throw new InvalidOperationException($"Only a single Core instance can be created"); + } + + // Store reference to engine for global member access. + s_instance = this; + + // Create a new graphics device manager. + Graphics = new GraphicsDeviceManager(this); + + // Set the graphics defaults + Graphics.PreferredBackBufferWidth = width; + Graphics.PreferredBackBufferHeight = height; + Graphics.IsFullScreen = fullScreen; + + // Apply the graphic presentation changes + Graphics.ApplyChanges(); + + // Set the window title + Window.Title = title; + + // Set the core's content manager to a reference of hte base Game's + // content manager. + Content = base.Content; + + // Set the root directory for content + Content.RootDirectory = "Content"; + + // Mouse is visible by default + IsMouseVisible = true; + } + + protected override void Initialize() + { + base.Initialize(); + + // Set the core's graphics device to a reference of the base Game's + // graphics device. + GraphicsDevice = base.GraphicsDevice; + + // Create the sprite batch instance. + SpriteBatch = new SpriteBatch(GraphicsDevice); + + // Create a new input manager + Input = new InputManager(); + } + + protected override void Update(GameTime gameTime) + { + // Update the input manager + Input.Update(gameTime); + + if (ExitOnEscape && Input.Keyboard.IsKeyDown(Keys.Escape)) + { + Exit(); + } + + base.Update(gameTime); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/11_input_management/snippets/game1.cs b/articles/tutorials/building_2d_games/11_input_management/snippets/game1.cs new file mode 100644 index 00000000..ab380346 --- /dev/null +++ b/articles/tutorials/building_2d_games/11_input_management/snippets/game1.cs @@ -0,0 +1,177 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; +using MonoGameLibrary.Input; + +namespace DungeonSlime; + +public class Game1 : Core +{ + // Defines the slime animated sprite. + private AnimatedSprite _slime; + + // Defines the bat animated sprite. + private AnimatedSprite _bat; + + // Tracks the position of the slime. + private Vector2 _slimePosition; + + // Speed multiplier when moving. + private const float MOVEMENT_SPEED = 5.0f; + + public Game1() : base("Dungeon Slime", 1280, 720, false) + { + + } + + protected override void Initialize() + { + // TODO: Add your initialization logic here + + base.Initialize(); + } + + protected override void LoadContent() + { + // Create the texture atlas from the XML configuration file + TextureAtlas atlas = TextureAtlas.FromFile(Content, "images/atlas-definition.xml"); + + // Create the slime animated sprite from the atlas. + _slime = atlas.CreateAnimatedSprite("slime-animation"); + _slime.Scale = new Vector2(4.0f, 4.0f); + + // Create the bat animated sprite from the atlas. + _bat = atlas.CreateAnimatedSprite("bat-animation"); + _bat.Scale = new Vector2(4.0f, 4.0f); + } + + protected override void Update(GameTime gameTime) + { + if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape)) + Exit(); + + // Update the slime animated sprite. + _slime.Update(gameTime); + + // Update the bat animated sprite. + _bat.Update(gameTime); + + // Check for keyboard input and handle it. + CheckKeyboardInput(); + + // Check for gamepad input and handle it. + CheckGamePadInput(); + + base.Update(gameTime); + } + + private void CheckKeyboardInput() + { + // If the space key is held down, the movement speed increases by 1.5 + float speed = MOVEMENT_SPEED; + if (Input.Keyboard.IsKeyDown(Keys.Space)) + { + speed *= 1.5f; + } + + // If the W or Up keys are down, move the slime up on the screen. + if (Input.Keyboard.IsKeyDown(Keys.W) || Input.Keyboard.IsKeyDown(Keys.Up)) + { + _slimePosition.Y -= speed; + } + + // if the S or Down keys are down, move the slime down on the screen. + if (Input.Keyboard.IsKeyDown(Keys.S) || Input.Keyboard.IsKeyDown(Keys.Down)) + { + _slimePosition.Y += speed; + } + + // If the A or Left keys are down, move the slime left on the screen. + if (Input.Keyboard.IsKeyDown(Keys.A) || Input.Keyboard.IsKeyDown(Keys.Left)) + { + _slimePosition.X -= speed; + } + + // If the D or Right keys are down, move the slime right on the screen. + if (Input.Keyboard.IsKeyDown(Keys.D) || Input.Keyboard.IsKeyDown(Keys.Right)) + { + _slimePosition.X += speed; + } + } + + private void CheckGamePadInput() + { + GamePadInfo gamePadOne = Input.GamePads[(int)PlayerIndex.One]; + + // If the A button is held down, the movement speed increases by 1.5 + // and the gamepad vibrates as feedback to the player. + float speed = MOVEMENT_SPEED; + if (gamePadOne.IsButtonDown(Buttons.A)) + { + speed *= 1.5f; + gamePadOne.SetVibration(1.0f, TimeSpan.FromSeconds(1)); + } + else + { + gamePadOne.StopVibration(); + } + + // Check thumbstick first since it has priority over which gamepad input + // is movement. It has priority since the thumbstick values provide a + // more granular analog value that can be used for movement. + if (gamePadOne.LeftThumbStick != Vector2.Zero) + { + _slimePosition.X += gamePadOne.LeftThumbStick.X * speed; + _slimePosition.Y -= gamePadOne.LeftThumbStick.Y * speed; + } + else + { + // If DPadUp is down, move the slime up on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadUp)) + { + _slimePosition.Y -= speed; + } + + // If DPadDown is down, move the slime down on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadDown)) + { + _slimePosition.Y += speed; + } + + // If DPapLeft is down, move the slime left on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadLeft)) + { + _slimePosition.X -= speed; + } + + // If DPadRight is down, move the slime right on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadRight)) + { + _slimePosition.X += speed; + } + } + } + + protected override void Draw(GameTime gameTime) + { + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + // Draw the slime sprite. + _slime.Draw(SpriteBatch, _slimePosition); + + // Draw the bat sprite 10px to the right of the slime. + _bat.Draw(SpriteBatch, new Vector2(_slime.Width + 10, 0)); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/11_input_management/snippets/gamepadinfo.cs b/articles/tutorials/building_2d_games/11_input_management/snippets/gamepadinfo.cs new file mode 100644 index 00000000..5999999f --- /dev/null +++ b/articles/tutorials/building_2d_games/11_input_management/snippets/gamepadinfo.cs @@ -0,0 +1,163 @@ +#region declaration +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace MonoGameLibrary.Input; + +public class GamePadInfo +{ + +} +#endregion +{ + #region member_fields + private TimeSpan _vibrationTimeRemaining = TimeSpan.Zero; + #endregion + + #region properties_playerindex + /// + /// Gets the index of the player this gamepad is for. + /// + public PlayerIndex PlayerIndex { get; } + #endregion + + #region properties_state + /// + /// Gets the state of input for this gamepad during the previous update cycle. + /// + public GamePadState PreviousState { get; private set; } + + /// + /// Gets the state of input for this gamepad during the current update cycle. + /// + public GamePadState CurrentState { get; private set; } + #endregion + + #region properties_connected + /// + /// Gets a value that indicates if this gamepad is currently connected. + /// + public bool IsConnected => CurrentState.IsConnected; + #endregion + + #region properties_thumbsticks_triggers + /// + /// Gets the value of the left thumbstick of this gamepad. + /// + public Vector2 LeftThumbStick => CurrentState.ThumbSticks.Left; + + /// + /// Gets the value of the right thumbstick of this gamepad. + /// + public Vector2 RightThumbStick => CurrentState.ThumbSticks.Right; + + /// + /// Gets the value of the left trigger of this gamepad. + /// + public float LeftTrigger => CurrentState.Triggers.Left; + + /// + /// Gets the value of the right trigger of this gamepad. + /// + public float RightTrigger => CurrentState.Triggers.Right; + #endregion + + #region ctors + /// + /// Creates a new GamePadInfo for the gamepad connected at the specified player index. + /// + /// The index of the player for this gamepad. + public GamePadInfo(PlayerIndex playerIndex) + { + PlayerIndex = playerIndex; + PreviousState = new GamePadState(); + CurrentState = GamePad.GetState(playerIndex); + } + #endregion + + #region methods_update + /// + /// Updates the state information for this gamepad input. + /// + /// + public void Update(GameTime gameTime) + { + PreviousState = CurrentState; + CurrentState = GamePad.GetState(PlayerIndex); + + if (_vibrationTimeRemaining > TimeSpan.Zero) + { + _vibrationTimeRemaining -= gameTime.ElapsedGameTime; + + if (_vibrationTimeRemaining <= TimeSpan.Zero) + { + StopVibration(); + } + } + } + #endregion + + #region methods_buttonstate + /// + /// Returns a value that indicates whether the specified gamepad button is current down. + /// + /// The gamepad button to check. + /// true if the specified gamepad button is currently down; otherwise, false. + public bool IsButtonDown(Buttons button) + { + return CurrentState.IsButtonDown(button); + } + + /// + /// Returns a value that indicates whether the specified gamepad button is currently up. + /// + /// The gamepad button to check. + /// true if the specified gamepad button is currently up; otherwise, false. + public bool IsButtonUp(Buttons button) + { + return CurrentState.IsButtonUp(button); + } + + /// + /// Returns a value that indicates whether the specified gamepad button was just pressed on the current frame. + /// + /// + /// true if the specified gamepad button was just pressed on the current frame; otherwise, false. + public bool WasButtonJustPressed(Buttons button) + { + return CurrentState.IsButtonDown(button) && PreviousState.IsButtonUp(button); + } + + /// + /// Returns a value that indicates whether the specified gamepad button was just released on the current frame. + /// + /// + /// true if the specified gamepad button was just released on the current frame; otherwise, false. + public bool WasButtonJustReleased(Buttons button) + { + return CurrentState.IsButtonUp(button) && PreviousState.IsButtonDown(button); + } + #endregion + + #region methods_vibration + /// + /// Sets the vibration for all motors of this gamepad. + /// + /// The strength of the vibration from 0.0f (none) to 1.0f (full). + /// The amount of time the vibration should occur. + public void SetVibration(float strength, TimeSpan time) + { + _vibrationTimeRemaining = time; + GamePad.SetVibration(PlayerIndex, strength, strength); + } + + /// + /// Stops the vibration of all motors for this gamepad. + /// + public void StopVibration() + { + GamePad.SetVibration(PlayerIndex, 0.0f, 0.0f); + } + #endregion +} diff --git a/articles/tutorials/building_2d_games/11_input_management/snippets/inputmanager.cs b/articles/tutorials/building_2d_games/11_input_management/snippets/inputmanager.cs new file mode 100644 index 00000000..68d765e4 --- /dev/null +++ b/articles/tutorials/building_2d_games/11_input_management/snippets/inputmanager.cs @@ -0,0 +1,61 @@ +#region declaration +using Microsoft.Xna.Framework; + +namespace MonoGameLibrary.Input; + +public class InputManager { } + +#endregion +{ + #region properties + /// + /// Gets the state information of keyboard input. + /// + public KeyboardInfo Keyboard { get; private set; } + + /// + /// Gets the state information of mouse input. + /// + public MouseInfo Mouse { get; private set; } + + /// + /// Gets the state information of a gamepad. + /// + public GamePadInfo[] GamePads { get; private set; } + #endregion + + #region ctors + /// + /// Creates a new InputManager. + /// + /// The game this input manager belongs to. + public InputManager() + { + Keyboard = new KeyboardInfo(); + Mouse = new MouseInfo(); + + GamePads = new GamePadInfo[4]; + for (int i = 0; i < 4; i++) + { + GamePads[i] = new GamePadInfo((PlayerIndex)i); + } + } + #endregion + + #region methods + /// + /// Updates the state information for the keyboard, mouse, and gamepad inputs. + /// + /// A snapshot of the timing values for the current frame. + public void Update(GameTime gameTime) + { + Keyboard.Update(); + Mouse.Update(); + + for (int i = 0; i < 4; i++) + { + GamePads[i].Update(gameTime); + } + } + #endregion +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/11_input_management/snippets/key_down_every_frame.cs b/articles/tutorials/building_2d_games/11_input_management/snippets/key_down_every_frame.cs new file mode 100644 index 00000000..d1075b4a --- /dev/null +++ b/articles/tutorials/building_2d_games/11_input_management/snippets/key_down_every_frame.cs @@ -0,0 +1,8 @@ +// Get the current state of keyboard input. +KeyboardState keyboardState = Keyboard.GetState(); + +// Check if the space key is down. +if (keyboardState.IsKeyDown(Keys.Space)) +{ + // This runs EVERY frame the space key is held down +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/11_input_management/snippets/keyboardinfo.cs b/articles/tutorials/building_2d_games/11_input_management/snippets/keyboardinfo.cs new file mode 100644 index 00000000..4d6e2518 --- /dev/null +++ b/articles/tutorials/building_2d_games/11_input_management/snippets/keyboardinfo.cs @@ -0,0 +1,85 @@ +#region declaration +using Microsoft.Xna.Framework.Input; + +namespace MonoGameLibrary.Input; + +public class KeyboardInfo { } +#endregion +{ + #region members + /// + /// Gets the state of keyboard input during the previous update cycle. + /// + public KeyboardState PreviousState { get; private set; } + + /// + /// Gets the state of keyboard input during the current input cycle. + /// + public KeyboardState CurrentState { get; private set; } + #endregion + + #region ctors + /// + /// Creates a new KeyboardInfo + /// + public KeyboardInfo() + { + PreviousState = new KeyboardState(); + CurrentState = Keyboard.GetState(); + } + #endregion + + + #region methods_update + /// + /// Updates the state information about keyboard input. + /// + public void Update() + { + PreviousState = CurrentState; + CurrentState = Keyboard.GetState(); + } + #endregion + + #region methods_keystate + /// + /// Returns a value that indicates if the specified key is currently down. + /// + /// The key to check. + /// true if the specified key is currently down; otherwise, false. + public bool IsKeyDown(Keys key) + { + return CurrentState.IsKeyDown(key); + } + + /// + /// Returns a value that indicates whether the specified key is currently up. + /// + /// The key to check. + /// true if the specified key is currently up; otherwise, false. + public bool IsKeyUp(Keys key) + { + return CurrentState.IsKeyUp(key); + } + + /// + /// Returns a value that indicates if the specified key was just pressed on the current frame. + /// + /// The key to check. + /// true if the specified key was just pressed on the current frame; otherwise, false. + public bool WasKeyJustPressed(Keys key) + { + return CurrentState.IsKeyDown(key) && PreviousState.IsKeyUp(key); + } + + /// + /// Returns a value that indicates if the specified key was just released on the current frame. + /// + /// The key to check. + /// true if the specified key was just released on the current frame; otherwise, false. + public bool WasKeyJustReleased(Keys key) + { + return CurrentState.IsKeyUp(key) && PreviousState.IsKeyDown(key); + } + #endregion +} diff --git a/articles/tutorials/building_2d_games/11_input_management/snippets/mousebutton.cs b/articles/tutorials/building_2d_games/11_input_management/snippets/mousebutton.cs new file mode 100644 index 00000000..dc789516 --- /dev/null +++ b/articles/tutorials/building_2d_games/11_input_management/snippets/mousebutton.cs @@ -0,0 +1,10 @@ +namespace MonoGameLibrary.Input; + +public enum MouseButton +{ + Left, + Middle, + Right, + XButton1, + XButton2 +} diff --git a/articles/tutorials/building_2d_games/11_input_management/snippets/mouseinfo.cs b/articles/tutorials/building_2d_games/11_input_management/snippets/mouseinfo.cs new file mode 100644 index 00000000..a9f11cf3 --- /dev/null +++ b/articles/tutorials/building_2d_games/11_input_management/snippets/mouseinfo.cs @@ -0,0 +1,229 @@ +#region declaration +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace MonoGameLibrary.Input; + +public class MouseInfo +{ + +} +#endregion +{ + #region properties_states + /// + /// The state of mouse input during the previous update cycle. + /// + public MouseState PreviousState { get; private set; } + + /// + /// The state of mouse input during the current update cycle. + /// + public MouseState CurrentState { get; private set; } + #endregion + + #region properties_position + /// + /// Gets or Sets the current position of the mouse cursor in screen space. + /// + public Point Position + { + get => CurrentState.Position; + set => SetPosition(value.X, value.Y); + } + + /// + /// Gets or Sets the current x-coordinate position of the mouse cursor in screen space. + /// + public int X + { + get => CurrentState.X; + set => SetPosition(value, CurrentState.Y); + } + + /// + /// Gets or Sets the current y-coordinate position of the mouse cursor in screen space. + /// + public int Y + { + get => CurrentState.Y; + set => SetPosition(CurrentState.X, value); + } + #endregion + + #region properties_position_delta + /// + /// Gets the difference in the mouse cursor position between the previous and current frame. + /// + public Point PositionDelta => CurrentState.Position - PreviousState.Position; + + /// + /// Gets the difference in the mouse cursor x-position between the previous and current frame. + /// + public int XDelta => CurrentState.X - PreviousState.X; + + /// + /// Gets the difference in the mouse cursor y-position between the previous and current frame. + /// + public int YDelta => CurrentState.Y - PreviousState.Y; + + /// + /// Gets a value that indicates if the mouse cursor moved between the previous and current frames. + /// + public bool WasMoved => PositionDelta != Point.Zero; + #endregion + + #region properties_scrollwheel + /// + /// Gets the cumulative value of the mouse scroll wheel since the start of the game. + /// + public int ScrollWheel => CurrentState.ScrollWheelValue; + + /// + /// Gets the value of the scroll wheel between the previous and current frame. + /// + public int ScrollWheelDelta => CurrentState.ScrollWheelValue - PreviousState.ScrollWheelValue; + #endregion + + #region ctors + /// + /// Creates a new MouseInfo. + /// + public MouseInfo() + { + PreviousState = new MouseState(); + CurrentState = Mouse.GetState(); + } + #endregion + + #region methods_update + /// + /// Updates the state information about mouse input. + /// + public void Update() + { + PreviousState = CurrentState; + CurrentState = Mouse.GetState(); + } + #endregion + + #region methods_buttonstate + /// + /// Returns a value that indicates whether the specified mouse button is currently down. + /// + /// The mouse button to check. + /// true if the specified mouse button is currently down; otherwise, false. + public bool IsButtonDown(MouseButton button) + { + switch (button) + { + case MouseButton.Left: + return CurrentState.LeftButton == ButtonState.Pressed; + case MouseButton.Middle: + return CurrentState.MiddleButton == ButtonState.Pressed; + case MouseButton.Right: + return CurrentState.RightButton == ButtonState.Pressed; + case MouseButton.XButton1: + return CurrentState.XButton1 == ButtonState.Pressed; + case MouseButton.XButton2: + return CurrentState.XButton2 == ButtonState.Pressed; + default: + return false; + } + } + + /// + /// Returns a value that indicates whether the specified mouse button is current up. + /// + /// The mouse button to check. + /// true if the specified mouse button is currently up; otherwise, false. + public bool IsButtonUp(MouseButton button) + { + switch (button) + { + case MouseButton.Left: + return CurrentState.LeftButton == ButtonState.Released; + case MouseButton.Middle: + return CurrentState.MiddleButton == ButtonState.Released; + case MouseButton.Right: + return CurrentState.RightButton == ButtonState.Released; + case MouseButton.XButton1: + return CurrentState.XButton1 == ButtonState.Released; + case MouseButton.XButton2: + return CurrentState.XButton2 == ButtonState.Released; + default: + return false; + } + } + + /// + /// Returns a value that indicates whether the specified mouse button was just pressed on the current frame. + /// + /// The mouse button to check. + /// true if the specified mouse button was just pressed on the current frame; otherwise, false. + public bool WasButtonJustPressed(MouseButton button) + { + switch (button) + { + case MouseButton.Left: + return CurrentState.LeftButton == ButtonState.Pressed && PreviousState.LeftButton == ButtonState.Released; + case MouseButton.Middle: + return CurrentState.MiddleButton == ButtonState.Pressed && PreviousState.MiddleButton == ButtonState.Released; + case MouseButton.Right: + return CurrentState.RightButton == ButtonState.Pressed && PreviousState.RightButton == ButtonState.Released; + case MouseButton.XButton1: + return CurrentState.XButton1 == ButtonState.Pressed && PreviousState.XButton1 == ButtonState.Released; + case MouseButton.XButton2: + return CurrentState.XButton2 == ButtonState.Pressed && PreviousState.XButton2 == ButtonState.Released; + default: + return false; + } + } + + /// + /// Returns a value that indicates whether the specified mouse button was just released on the current frame. + /// + /// The mouse button to check. + /// true if the specified mouse button was just released on the current frame; otherwise, false.F + public bool WasButtonJustReleased(MouseButton button) + { + switch (button) + { + case MouseButton.Left: + return CurrentState.LeftButton == ButtonState.Released && PreviousState.LeftButton == ButtonState.Pressed; + case MouseButton.Middle: + return CurrentState.MiddleButton == ButtonState.Released && PreviousState.MiddleButton == ButtonState.Pressed; + case MouseButton.Right: + return CurrentState.RightButton == ButtonState.Released && PreviousState.RightButton == ButtonState.Pressed; + case MouseButton.XButton1: + return CurrentState.XButton1 == ButtonState.Released && PreviousState.XButton1 == ButtonState.Pressed; + case MouseButton.XButton2: + return CurrentState.XButton2 == ButtonState.Released && PreviousState.XButton2 == ButtonState.Pressed; + default: + return false; + } + } + #endregion + + #region methods_setposition + /// + /// Sets the current position of the mouse cursor in screen space and updates the CurrentState with the new position. + /// + /// The x-coordinate location of the mouse cursor in screen space. + /// The y-coordinate location of the mouse cursor in screen space. + public void SetPosition(int x, int y) + { + Mouse.SetPosition(x, y); + CurrentState = new MouseState( + x, + y, + CurrentState.ScrollWheelValue, + CurrentState.LeftButton, + CurrentState.MiddleButton, + CurrentState.RightButton, + CurrentState.XButton1, + CurrentState.XButton2 + ); + } + #endregion +} diff --git a/articles/tutorials/building_2d_games/11_input_management/videos/input-moving-slime.webm b/articles/tutorials/building_2d_games/11_input_management/videos/input-moving-slime.webm new file mode 100644 index 00000000..a4d21c4a Binary files /dev/null and b/articles/tutorials/building_2d_games/11_input_management/videos/input-moving-slime.webm differ diff --git a/articles/tutorials/building_2d_games/12_collision_detection/images/aabb-collision-example.svg b/articles/tutorials/building_2d_games/12_collision_detection/images/aabb-collision-example.svg new file mode 100644 index 00000000..f253d849 --- /dev/null +++ b/articles/tutorials/building_2d_games/12_collision_detection/images/aabb-collision-example.svg @@ -0,0 +1,93 @@ + + + +Axis-Aligned Bounding Box Collision Example diff --git a/articles/tutorials/building_2d_games/12_collision_detection/images/aabb-vs-non-aabb.svg b/articles/tutorials/building_2d_games/12_collision_detection/images/aabb-vs-non-aabb.svg new file mode 100644 index 00000000..b326edda --- /dev/null +++ b/articles/tutorials/building_2d_games/12_collision_detection/images/aabb-vs-non-aabb.svg @@ -0,0 +1,199 @@ + + + +Axis-Aligned Bounding Box ExampleScreen Y-AxisScreen X-AxisAxis AlignedNot Axis-Aligned diff --git a/articles/tutorials/building_2d_games/12_collision_detection/images/circle-collision.svg b/articles/tutorials/building_2d_games/12_collision_detection/images/circle-collision.svg new file mode 100644 index 00000000..cd9c4f0a --- /dev/null +++ b/articles/tutorials/building_2d_games/12_collision_detection/images/circle-collision.svg @@ -0,0 +1,635 @@ + + + + + + + + + + + + + + + + + + Sum of Radii = 10 + + + + + + + 5 + (25, 10) + + + + 5 + (10, 10) + + + + + Distance = 15 + + + + + + Not Colliding + + + + + + + + 5 + (25, 10) + + + + 5 + (15, 10) + + + + Sum of Radii = 10 + + Distance = 10 + + + + Not Colliding + + + + Colliding + + Sum of Radii = 10 + + Distance = 8 + + + + + + + 5 + (10, 10) + + + + + + 5 + (18, 10) + + + + + + + + Colliding + + Sum of Radii = 10 + + + Distance = 9.43 + + + + + + + + 5 + (10, 15) + + + + + + 5 + (18, 10) + + + + + + + diff --git a/articles/tutorials/building_2d_games/12_collision_detection/images/circle-distance-right-triangle.svg b/articles/tutorials/building_2d_games/12_collision_detection/images/circle-distance-right-triangle.svg new file mode 100644 index 00000000..55530dec --- /dev/null +++ b/articles/tutorials/building_2d_games/12_collision_detection/images/circle-distance-right-triangle.svg @@ -0,0 +1,96 @@ + + + + + + + + + + + + c + a + b + + diff --git a/articles/tutorials/building_2d_games/12_collision_detection/images/reflection-diagram.svg b/articles/tutorials/building_2d_games/12_collision_detection/images/reflection-diagram.svg new file mode 100644 index 00000000..29588376 --- /dev/null +++ b/articles/tutorials/building_2d_games/12_collision_detection/images/reflection-diagram.svg @@ -0,0 +1,298 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Incoming Vector + Normal Vector + Reflection Vector + θ + θ + + + + + + + + + + + + diff --git a/articles/tutorials/building_2d_games/12_collision_detection/index.md b/articles/tutorials/building_2d_games/12_collision_detection/index.md new file mode 100644 index 00000000..4736510b --- /dev/null +++ b/articles/tutorials/building_2d_games/12_collision_detection/index.md @@ -0,0 +1,419 @@ +--- +title: "Chapter 12: Collision Detection" +description: "Learn how to implement collision detection between game objects and handle collision responses like blocking, triggering events, and bouncing." +--- + +In [Chapter 11](../11_input_management/index.md), you learned how to manage player input to control game objects. However, for objects in your game to interact with each other, collecting items, hitting obstacles, or triggering events, you need to detect when these objects come into contact. This is accomplished through collision detection. + +In this chapter you will: + +- Understand different collision shapes and their use cases. +- Implement rectangle-based collision detection. +- Create circle-based collision detection. +- Learn how to handle object overlap and response. +- Build a reusable collision system for your game. + +We will first start by understanding the basics of collision detection and the different approaches that can be used. + +> [!NOTE] +> There is a lot to understand when it comes to collision detection and the many complex ways that two objects can be considered IN collsion or NEAR collision. It is critical to get an understanding of the basics before jumping into code. So buckle up, we have a story to tell before you can get back to the keyboard. +> +> Feel free to keep coming back to this chapter and refer to the content when you need to, with a fresh cup of coffee. + +## Understanding Collision Detection + +Before we start implementing collision detection, we should discuss what collision detection actually is. In 2D games, collision detection involves checking if two objects interact with each other in some way. There are several approaches to detecting collisions, ranging from simple to complex: + +### Proximity Collision Detection + +The simplest form is checking if objects are within a certain range of each other. This is useful when you only need to know if objects are "near" each other like detecting if an enemy is close enough to chase a player or if two objects are close enough to perform a more complex collision check. + +### Simple Shape Based Collision Detection + +Shaped based collision detection checks if two shapes overlap. The most common and simple shapes used are circles and rectangles: + +#### Circle Collision Detection + +Circle collision detection is computationally a simpler check than that rectangles. There are also no special considerations if the circles are rotated, which makes them easier to use. To determine if two circle shapes are overlapping, we only need to check if the square of the sum of the radii between the two circles is less than the squared distance between the two circles with the following formula: + +Two find the distance between two circles, imagine drawing a line from the center of one circle to the center of the other. This length of this line is the distance, but we could also calculate it by first walking up or down and then walking left or right from the center of one circle to another, forming a right triangle. + +| ![Figure 12-1: Showing the distance between the center of two circles forms a right triangle](./images/circle-distance-right-triangle.svg) | +| :---------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 12-1: Showing the distance between the center of two circles forms a right triangle** | + +In the *Figure 12-1* above + +- $a$ is the distance between the center of the two on the x-axis (horizontal). +- $b$ is the distance between the center of the two circles on the y-axis (vertical). +- $c$ is the total distance between the center of the two circles. + +Since this forms a right triangle, to calculate the squared distance, we can use Pythagorean's Theorem: + +$$c^2 = a^2 + b^2$$ + +Then we just check if the squared sum of the radii of the two circles is less than the squared distance: + +$$(radius_{circle1} + radius_{circle2})^2 < c^2$$ + +If it is less, then the circles are overlapping; otherwise, they are not. + +To calculate the squared distance between to points, MonoGame provides the [**Vector2.DistanceSquared**](xref:Microsoft.Xna.Framework.Vector2.DistanceSquared(Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Vector2)) method: + +[!code-csharp[](./snippets/vector2_distance.cs)] + +> [!TIP] +> MonoGame also provides a distance calculation method with [**Vector2.Distance**](xref:Microsoft.Xna.Framework.Vector2.Distance(Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Vector2)) which returns the distance by providing the square root of the distance squared. So why do not we use this instead? +> +> Square root operations are more computationally complex for a CPU. So instead of getting the normal distance, which would require the square root operation, it is more efficient for the cpu to multiply the sum of the radii by itself to get the squared sum and use that for comparison instead. + +#### Rectangle Collision Detection + +Rectangles, often called *bounding boxes*, typically uses what is called *Axis-Aligned Bounding Box* (AABB) collision detection to determine if two rectangle shapes overlap. Unlike circles, to perform AABB collision detection, the x- and y-axes of both rectangles must be aligned with the x- and y-axes of the screen. This is just another way of saying that the rectangles cannot be rotated. + +| ![Figure 12-2: The rectangle on the left is axis-aligned since both the axes are aligned with the screen axes. The rectangle on the right is non axis-aligned since it is rotated and the axes do not align with the screen axe.](./images/aabb-vs-non-aabb.svg) | +| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 12-2: The rectangle on the left is axis-aligned since both the axes are aligned with the screen axes. The rectangle on the right is non axis-aligned since it is rotated and the axes do not align with the screen axes** | + +MonoGame provides the [**Rectangle**](xref:Microsoft.Xna.Framework.Rectangle) struct which represents a rectangle by its position (X,Y) and size (Width,Height). The following table shows some of the properties of the [**Rectangle**](xref:Microsoft.Xna.Framework.Rectangle) struct: + +| Property | Type | Description | +| ----------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [**Bottom**](xref:Microsoft.Xna.Framework.Rectangle.Bottom) | `int` | Returns the y-coordinate location of the bottom edge of the rectangle. This is equal to [**Rectangle.Y**](xref:Microsoft.Xna.Framework.Rectangle.Y) plus the height of the rectangle. | +| [**Left**](xref:Microsoft.Xna.Framework.Rectangle.Left) | `int` | Returns the x-coordinate location of the left edge of the rectangle. This is equal to [**Rectangle.X**](xref:Microsoft.Xna.Framework.Rectangle.X). | +| [**Right**](xref:Microsoft.Xna.Framework.Rectangle.Right) | `int` | Returns the x-coordinate location of the right edge of the rectangle. This is equal to [**Rectangle.X**](xref:Microsoft.Xna.Framework.Rectangle.X) plus the width of the rectangle. | +| [**Top**](xref:Microsoft.Xna.Framework.Rectangle.Top) | `int` | Returns the y-coordinate location of the top edge of the rectangle. This is equal to [**Rectangle.Y**](xref:Microsoft.Xna.Framework.Rectangle.Y). | + +To determine if two rectangles overlap using AABB collision detection, there are four conditions that need to be checked, and all four conditions must be true. Given two rectangles $A$ and $B$, these conditions are: + +1. $A_{Left}$ must be less than $B_{Right}$. +2. $A_{Right}$ must be greater than $B_{Left}$. +3. $A_{Top}$ must be less than $B_{Bottom}$. +4. $A_{Bottom}$ must be greater than $B_{Top}$. + +If even a single one of these conditions is false, then the rectangles are not overlapping and thus not colliding. + +MonoGame provides the [**Rectangle.Intersects**](xref:Microsoft.Xna.Framework.Rectangle.Intersects(Microsoft.Xna.Framework.Rectangle)) method which will perform an AABB collision check for us: + +[!code-csharp[](./snippets/rectangle_intersects.cs)] + +| ![Figure 12-3: The rectangle on the left is overlapping the rectangle on the right based on the conditions required for the Axis-Aligned Bounding Box collision check](./images/aabb-collision-example.svg) | +| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 12-3: The rectangle on the left is overlapping the rectangle on the right based on the conditions required for the Axis-Aligned Bounding Box collision check** | + +#### Complex Polygon Collision Detection + +Complex polygon collision detection uses a method called *Separating Axis Theorem* (SAT) to determine if two polygon shapes overlap. SAT uses more complex calculations that can determine if any polygon shape overlaps with another polygon shape, including if they are rotated. There are performance considerations to consider when using SAT. + +Implementing SAT is out-of-scope for this tutorial. If you are interested in further reading about this, please see the following articles as a good starting point: + +- [Separating Axis Theorem (SAT) Explanation](https://www.sevenson.com.au/actionscript/sat/). +- [Collision Detection Using the Separating Axis Theorem](https://gamedevelopment.tutsplus.com/tutorials/collision-detection-using-the-separating-axis-theorem--gamedev-169) by Kah Shiu Chong. +- [N Tutorial A - Collision Detection and Response](http://www.metanetsoftware.com/technique/tutorialA.html). + +#### Choosing a Collision Detection Method + +When determining which collision detection method to use, you should start with the simplest one that meets the needs of your game. If distance checks work for your game mechanic, there's no need to implement more complex shape based detections. Similarly, if a circle can represent the bounding area of a game object, start with that before moving onto rectangles. + +Some other points to consider are + +- Circles: + - Better for round objects like balls and coins. + - More accurate for rotating objects. + - Simpler check for overlap than rectangles. +- Rectangles: + - Great for walls, platforms, and most game objects. + - Easy to visualize and debug. + - Works well with tile-based games. + +### Collision Detection vs Collision Response + +Often times when talking about collision detection, the term is used to mean both the detection of overlapping shapes and what to do once a positive check has occurred. What you do after a positive collision check has occurred is called the *collision response*. Some of the common responses are: + +#### Blocking Collision Response + +A blocking collision response is the most basic response which just prevents the two objects from overlapping. This is commonly used for walls, platforms and other solid objects. To perform a blocking collision response: + +1. Store the location of an object calculating the new location to move it to. +2. Check if it is overlapping an object at the new location: + +- If it is overlapping, then set the position to the the position before it was moved. +- If it is not overlapping, set the position to the new calculated position. + +For example: + +[!code-csharp[](./snippets/blocking_example.cs)] + +Sometimes, instead of preventing an object from moving onto another object, we want to ensure an object remains contained within a certain bounding area. MonoGame also provides the [**Rectangle.Contains**](xref:Microsoft.Xna.Framework.Rectangle.Contains(Microsoft.Xna.Framework.Rectangle)) method that we can use to determine this. [**Rectangle.Contains**](xref:Microsoft.Xna.Framework.Rectangle.Contains(Microsoft.Xna.Framework.Rectangle)) can check if any of the following are completely contained within the bounds of the rectangle; + +- [**Point**](xref:Microsoft.Xna.Framework.Point) +- [**Rectangle**](xref:Microsoft.Xna.Framework.Rectangle) +- [**Vector2**](xref:Microsoft.Xna.Framework.Vector2) + +For example, if we wanted to perform a blocking collision response that ensure a sprite remained contained within the bounds of the game screen: + +[!code-csharp[](./snippets/contains_example.cs)] + +> [!TIP] +> Use [**GraphicsDevice.PresentationParameters**](xref:Microsoft.Xna.Framework.Graphics.GraphicsDevice.PresentationParameters) to get the actual screen dimensions instead of [**GraphicsDeviceManager.PreferredBackBufferWidth**](xref:Microsoft.Xna.Framework.GraphicsDeviceManager.PreferredBackBufferWidth) and [**GraphicsDeviceManager.PreferredBackBufferHeight**](xref:Microsoft.Xna.Framework.GraphicsDeviceManager.PreferredBackBufferHeight). The preferred values are only hints and may not reflect the actual back buffer size. + +#### Trigger Collision Response + +Sometimes you want to trigger an event, rather than block movement, when a collision occurs. Common examples include + +- Collecting items. +- Activating switches. +- Entering zones or areas. +- Triggering cutscenes. + +Performing a trigger collision response is just simply checking if the game object is overlapping with the bounding area of the trigger zone, and if so trigger the event. + +For example: + +[!code-csharp[](./snippets/trigger_example.cs)] + +#### Bounce Collision Response + +For games that need objects to bonce off each other (like a the ball in a Pong game), we need to calculate how their velocity should change after the collision. MonoGame provides the [**Vector2.Reflect**](xref:Microsoft.Xna.Framework.Vector2.Reflect(Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Vector2)) method to handle this calculation for us. The method needs two pieces of information: + +1. The incoming vector (the direction something is moving). +2. The normal vector (the direction perpendicular to the surface). + +| ![Figure 12-4: A diagram showing how an incoming vector reflects off of a surface base around the normal vector of the surface](./images/reflection-diagram.svg) | +| :--------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 12-4: A diagram showing how an incoming vector reflects off of a surface base around the normal vector of the surface** | + +As shown in the diagram above, when an incoming vector hits a surface, it reflects at the same angle ($\theta$) relative to the normal vector. + +> [!TIP] +> Think of the normal vector like the line you'd draw perpendicular to a mirror's surface. The angle between your incoming path and this line will be the same as the angle between your reflection and this line. + +For example, if we had a ball moving around the screen and wanted it to bounce off the edges of the screen: + +[!code-csharp[](./snippets/bounce_example.cs)] + +> [!TIP] +> [**Vector2.UnitX**](xref:Microsoft.Xna.Framework.Vector2.UnitX) is $(1, 0)$ and [**Vector2.UnitY**](xref:Microsoft.Xna.Framework.Vector2.UnitY) is $(0, 1)$. We use these to get the screen edge normal since the edges of the screen are not at an angle. For more complex surfaces, you would need to calculate the appropriate normal vector based on the surface angle + +### Optimizing Collision Performance + +When checking for collisions between multiple objects, testing every object against every other object (often called brute force checking) becomes inefficient as your game grows. Brute force checking can be calculated as $(n * (n - 1)) / 2$ where $n$ is the total number of objects. For example, if you have 100 objects in your game, that's $(100 * 99) / 2 = 4950$ collision checks every frame. To improve performance, we can use a two-phase approach: + +1. Broad Phase: A quick, simple check to rule out objects that definitely are not colliding. +2. Narrow Phase: A more precise check only performed on objects that passed the broad phase. + +For our simple game with just two objects, this optimization is not necessary. However, as you develop more complex games, implementing a broad-phase check can significantly improve performance. Later in this tutorial series we will implement an algorithm called spatial hashing to perform broad phase checks. + +> [!NOTE] +> Time to get back to the code! The fun starts again here. + +## The Circle Struct + +For our game, we are going to implement circle based collision detection. MonoGame does not have a `Circle` struct to represent a circle like it does with [**Rectangle**](xref:Microsoft.Xna.Framework.Rectangle). Before we can perform circle collision, we will need to create our own. + +In the root of the *MonoGameLibrary* project, add a new file named `Circle.cs`. Add the following code as the foundation of the `Circle` struct: + +[!code-csharp[](./snippets/cirlce.cs#declaration)] + +> [!NOTE] +> Notice that the struct has declared it will implement the [`IEquatable`](https://learn.microsoft.com/en-us/dotnet/api/system.iequatable-1) Interface. When creating value types like this, it is recommended to implement `IEquatable` because it has better performance for comparing objects and can help avoid boxing. +> +> For more information on recommended design guidelines for structs, see [Struct Design - Framework Design Guidelines | Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/struct) +> +> Ignore the red squiggles for now, they will be resolved as we add more code. + +### Circle Fields + +The `Circle` struct uses both private and public fields to store its state. + +First, add the following private static field that stores a reusable empty circle: + +[!code-csharp[](./snippets/cirlce.cs#fields_static)] + +Next, add the following public fields that define the circle's position and size: + +[!code-csharp[](./snippets/cirlce.cs#fields)] + +These public fields store the fundamental properties of the circle: + +- `X` and `Y` define the center point location. +- `Radius` defines how far the circle extends from its center. + +### Circle Properties + +The `Circle` struct provides properties to access its location, state, and its boundaries. + +Add the following property to get the location of the circle as a [**Point**](xref:Microsoft.Xna.Framework.Point) value: + +[!code-csharp[](./snippets/cirlce.cs#properties_location)] + +Add the following properties to track empty circles: + +[!code-csharp[](./snippets/cirlce.cs#properties_empty)] + +> [!NOTE] +> The `Empty` property returns a reusable instance of an empty circle stored in the private static field `s_empty`. This is more efficient than creating new empty circles each time one is needed, as it reuses the same instance in memory. + +Add the following properties for getting the circle's boundaries: + +[!code-csharp[](./snippets/cirlce.cs#properties_boundaries)] + +> [!TIP] +> These boundary properties are particularly useful when you need to know the extent of a circle in screen space, such as determining if a circle is visible on screen or creating a bounding box around the circle. + +### Circle Constructors + +The `Circle` struct provides two ways to create a new circle: + +[!code-csharp[](./snippets/cirlce.cs#ctors)] + +* The first constructor accepts individual x and y coordinates for the circle's center. +* The second accepts a [**Point**](xref:Microsoft.Xna.Framework.Point) struct that combines both coordinates. + +Both constructors require a radius value that defines the circle's size. + +### Circle Methods + +The `Circle` struct implements several methods to support equality comparison between circles. These methods allow us to check if two circles are identical (have the same center position and radius). + +First, add the following method that will check if two circles are overlapping with each other: + +[!code-csharp[](./snippets/cirlce.cs#methods_intersects)] + +Next we start implementing the [`IEquatable`](https://learn.microsoft.com/en-us/dotnet/api/system.iequatable-1) Interface, add the following methods for comparing a circle with another object: + +[!code-csharp[](./snippets/cirlce.cs#methods_equals)] + +Next, add the following override for `GetHashCode` to support using circles in hash-based collections: + +[!code-csharp[](./snippets/cirlce.cs#methods_hashcode)] + +Finally, add the following operator overloads to support using == and != with circles: + +[!code-csharp[](./snippets/cirlce.cs#methods_operators)] + +IEquatable interface implemented, red squiggles be gone. + +> [!TIP] +> The operator overloads allow you to compare circles using familiar syntax: +> +> [!code-csharp[](./snippets/circle_equal_example.cs)] + +Now that we have a struct to represent a circle and check for overlapping, we will update our game to implement collision detection and responses. + +## Adding Collision To Our Game + +If you run the game right now and move the slime around, you will notice a few issues that can be fixed by adding collision detection and response: + +1. You can move the slime outside the bounds of the screen. +2. Nothing occurs when the slime collides with the bat. +3. The bat does not move, providing no challenge in the game. + +We can now implement these features using collision detection and response in our game. In the *DungeonSlime* project (your main game project), open the `Game1.cs` file and make the following changes to the `Game1` class: + +[!code-csharp[](./snippets/game1.cs?highlight=1,5,25-29,40-45,79-179,184-196,296-297)] + +The key changes made here are: + +1. The field `_batPosition` was added to track the position of the bat. +2. The field `_batVelocity` was added to track the velocity of the bat. +3. The `AssignRandomBatVelocity()` method was added which calculates a random x and y velocity for the bat to move at when called. +4. In [**Initialize**](xref:Microsoft.Xna.Framework.Game.Initialize), the initial position of the bat is set and `AssignRandomVelocity` is called to assign the initial velocity for the bat. +5. In [**Update**](xref:Microsoft.Xna.Framework.Game.Update(Microsoft.Xna.Framework.GameTime)), collision detection and response logic was added to perform the following in order: + 1. A [**Rectangle**](xref:Microsoft.Xna.Framework.Rectangle) bound is created to represent the bounds of the screen. + 2. A `Circle` bound is created to represent the bounds of the slime. + 3. Distance based checks are performed to ensure that the slime cannot move outside of the screen, the resolution of which is to perform a blocking response. + 4. A new position for the bat is calculated based on the current velocity of the bat. + 5. A `Circle` bound is created to represent the bounds of the bat. + 6. Distance based checks are performed to ensure the bat cannot move outside of the screen, the resolution of which is to perform a bounce response. + 7. A collision check is made to determine if the slime and bat are colliding (bat "eating" the slime). If so, the bat is assigned a new random position within the screen and assigned a new random velocity. +6. In [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)), the bat is now drawn using the `_batPosition` value. + +Running the game now + +- The bat will start moving with a random velocity and bounce off the edges of the screen +- You can move the slime around, but cannot leave the bounds of the screen with the slime. +- If you move the slime to collide ("eat") the bat, the bat will respawn at a new location with a new velocity. + +| ![Figure 12-5: When the slime collides ("eats") the bat, the bat respawns in a new location on the screen with a random velocity assigned](./videos/gameplay.webm) | +| :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 12-5: When the slime collides ("eats") the bat, the bat respawns in a new location on the screen with a random velocity assigned** | + +## Conclusion + +In this chapter, you accomplished the following: + +- Learned about different approaches to collision detection: + - Distance-based checks for simple proximity detection. + - Shape-based checks using circles and rectangles. + - Complex polygon checks using SAT. +- Understood when to use different collision shapes: + - Circles for round objects and rotation. + - Rectangles for walls and platforms. +- Explored different types of collision responses: + - Blocking to prevent objects from overlapping. + - Triggering to cause events when objects collide. + - Bouncing to reflect objects off surfaces. +- Created reusable components: + - Implemented a Circle struct for circle-based collision. + - Added methods to detect circle intersection. +- Applied collision concepts to our game: + - Added screen boundary collision for the slime. + - Implemented bouncing behavior for the bat. + - Created a trigger response when the slime "eats" the bat. + +In the next chapter, we will explore using tilesets and tilemaps to create tile based environments for our game. + +## Test Your Knowledge + +1. What is the difference between collision detection and collision response? + + ::: question-answer + Collision detection is determining when two objects overlap or intersect, while collision response is what happens after a collision is detected (like blocking movement, triggering events, or bouncing objects off each other). + ::: + +2. When using Rectangle.Intersects for AABB collision, what four conditions must all be true for a collision to occur? + + ::: question-answer + For two rectangles A and B to collide: + + 1. A's left edge must be less than B's right edge + 2. A's right edge must be greater than B's left edge + 3. A's top edge must be less than B's bottom edge + 4. A's bottom edge must be greater than B's top edge + + ::: + +3. When implementing circle collision, why do we compare the distance between centers to the sum of the radii? + + ::: question-answer + Two circles are colliding if the distance between their centers is less than the sum of their radii. If the distance is greater, they are separate. If the distance equals the sum of radii, they are just touching at one point. + ::: + +4. When implementing bounce collision response, what two pieces of information does [**Vector2.Reflect**](xref:Microsoft.Xna.Framework.Vector2.Reflect(Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Vector2)) need? + + ::: question-answer + [**Vector2.Reflect**](xref:Microsoft.Xna.Framework.Vector2.Reflect(Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Vector2)) needs: + + 1. The incoming vector (direction the object is moving) + 2. The normal vector (direction perpendicular to the surface being hit) + + ::: + +5. Why might you choose to use circle collision over rectangle collision for certain objects? + + ::: question-answer + Circle collision might be chosen because: + + - It is more accurate for round objects + - It handles rotating objects better + - It is simpler for continuous collision detection + - It is natural for radius-based interactions + + ::: + +6. In the blocking collision response example, why do we store the previous position before handling input? + + ::: question-answer + We store the previous position so that if a collision occurs after movement, we can reset the object back to its last valid position. This prevents objects from moving through each other by undoing any movement that would cause overlap. + ::: diff --git a/articles/tutorials/building_2d_games/12_collision_detection/snippets/blocking_example.cs b/articles/tutorials/building_2d_games/12_collision_detection/snippets/blocking_example.cs new file mode 100644 index 00000000..0716995f --- /dev/null +++ b/articles/tutorials/building_2d_games/12_collision_detection/snippets/blocking_example.cs @@ -0,0 +1,31 @@ +// Store the current location +Vector2 previousLocation = _spriteLocation; + +// Calculate a new location +Vector2 newLocation = _spriteLocation + new Vector2(10, 0); + +// Create a bounding box for the sprite object +Rectangle spriteBounds = new Rectangle( + (int)newLocation.X, + (int)newLocation.Y, + (int)_sprite.Width, + (int)_sprite.Height +); + +// Create a bounding box for the blocking object +Rectangle blockingBounds = new Rectangle( + (int)_blockingLocation_.X, + (int)_blockingLocation_.Y, + (int)_blockingSprite_.Width, + (int)_blockingSprite_.Height +); + +// Detect if they are colliding +if(spriteBounds.Intersects(blockingBounds)) +{ + // Respond by not allowing the sprite to move by setting + // the location back to the previous location. + newLocation = previousLocation; +} + +_spriteLocation = newLocation; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/12_collision_detection/snippets/bounce_example.cs b/articles/tutorials/building_2d_games/12_collision_detection/snippets/bounce_example.cs new file mode 100644 index 00000000..b839521d --- /dev/null +++ b/articles/tutorials/building_2d_games/12_collision_detection/snippets/bounce_example.cs @@ -0,0 +1,68 @@ +// Calculate the new position of the ball based on the velocity +Vector2 newPosition = _ballPosition + _ballVelocity; + +// Get the bounds of the ball as a rectangle +Rectangle ballBounds = new Rectangle( + (int)_ballPosition.X, + (int)_ballPosition.Y, + (int)_ball.Width, + (int)_ball.Height +); + +// Get the bounds of the screen as a rectangle +Rectangle screenBounds = new Rectangle( + 0, + 0, + GraphicsDevice.PresentationParameters.BackBufferWidth, + GraphicsDevice.PresentationParameters.BackBufferHeight +); + +// Detect if the ball object is within the screen bounds +if(!screenBounds.Contains(ballBounds)) +{ + // Ball would move outside the screen + // First find the distance from the edge of the ball to each edge of the screen. + float distanceLeft = Math.Abs(screenBounds.Left - ballBounds.Left); + float distanceRight = Math.Abs(screenBounds.Right - ballBounds.Right); + float distanceTop = Math.Abs(screenBounds.Top - ballBounds.Top); + float distanceBottom = Math.Abs(screenBounds.Bottom - ballBounds.Bottom); + + // Determine which screen edge is the closest + float minDistance = Math.Min( + Math.Min(distanceLeft, distanceRight), + Math.Min(distanceTop, distanceBottom) + ); + + // Determine the normal vector based on which screen edge is the closest + Vector2 normal; + if (minDistance == distanceLeft) + { + // Closest to the left edge + normal = Vector2.UnitX; + newPosition.X = 0; + } + else if (minDistance == distanceRight) + { + // Closest to the right edge + normal = -Vector2.UnitX; + newPosition.X = screenBounds.Right - _ball.Width; + } + else if (minDistance == distanceTop) + { + // Closest to the top edge + normal = Vector2.UnitY; + newPosition.Y = 0; + } + else + { + // Closest to the bottom edge + normal = -Vector2.UnitY; + newPosition.Y = screenBounds.Bottom - _ball.Height; + } + + // Reflect the velocity about the normal + _ballVelocity = Vector2.Reflect(_ballVelocity, normal); +} + +// Set the new position of the ball +_ballVelocity = newPosition; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/12_collision_detection/snippets/circle_equal_example.cs b/articles/tutorials/building_2d_games/12_collision_detection/snippets/circle_equal_example.cs new file mode 100644 index 00000000..6c7f9790 --- /dev/null +++ b/articles/tutorials/building_2d_games/12_collision_detection/snippets/circle_equal_example.cs @@ -0,0 +1,3 @@ +Circle circle1 = new Circle(0, 0, 5); +Circle circle2 = new Circle(0, 0, 5); +bool areEqual = circle1 == circle2; // Returns true \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/12_collision_detection/snippets/cirlce.cs b/articles/tutorials/building_2d_games/12_collision_detection/snippets/cirlce.cs new file mode 100644 index 00000000..d8121981 --- /dev/null +++ b/articles/tutorials/building_2d_games/12_collision_detection/snippets/cirlce.cs @@ -0,0 +1,161 @@ +#region declaration +using System; +using Microsoft.Xna.Framework; + +namespace MonoGameLibrary; + +public readonly struct Circle : IEquatable +{ + +} +#endregion +{ + #region fields_static + private static readonly Circle s_empty = new Circle(); + #endregion + + #region fields + /// + /// The x-coordinate of the center of this circle. + /// + public readonly int X; + + /// + /// The y-coordinate of the center of this circle. + /// + public readonly int Y; + + /// + /// The length, in pixels, from the center of this circle to the edge. + /// + public readonly int Radius; + #endregion + + #region properties_location + /// + /// Gets the location of the center of this circle. + /// + public readonly Point Location => new Point(X, Y); + #endregion + + #region properties_empty + /// + /// Gets a circle with X=0, Y=0, and Radius=0. + /// + public static Circle Empty => s_empty; + + /// + /// Gets a value that indicates whether this circle has a radius of 0 and a location of (0, 0). + /// + public readonly bool IsEmpty => X == 0 && Y == 0 && Radius == 0; + #endregion + + #region properties_boundaries + /// + /// Gets the y-coordinate of the highest point on this circle. + /// + public readonly int Top => Y - Radius; + + /// + /// Gets the y-coordinate of the lowest point on this circle. + /// + public readonly int Bottom => Y + Radius; + + /// + /// Gets the x-coordinate of the leftmost point on this circle. + /// + public readonly int Left => X - Radius; + + /// + /// Gets the x-coordinate of the rightmost point on this circle. + /// + public readonly int Right => X + Radius; + #endregion + + #region ctors + /// + /// Creates a new circle with the specified position and radius. + /// + /// The x-coordinate of the center of the circle. + /// The y-coordinate of the center of the circle.. + /// The length from the center of the circle to an edge. + public Circle(int x, int y, int radius) + { + X = x; + Y = y; + Radius = radius; + } + + /// + /// Creates a new circle with the specified position and radius. + /// + /// The center of the circle. + /// The length from the center of the circle to an edge. + public Circle(Point location, int radius) + { + X = location.X; + Y = location.Y; + Radius = radius; + } + #endregion + + #region methods_intersects + /// + /// Returns a value that indicates whether the specified circle intersects with this circle. + /// + /// The other circle to check. + /// true if the other circle intersects with this circle; otherwise, false. + public bool Intersects(Circle other) + { + int radiiSquared = (this.Radius + other.Radius) * (this.Radius + other.Radius); + float distanceSquared = Vector2.DistanceSquared(this.Location.ToVector2(), other.Location.ToVector2()); + return distanceSquared < radiiSquared; + } + #endregion + + #region methods_equals + /// + /// Returns a value that indicates whether this circle and the specified object are equal + /// + /// The object to compare with this circle. + /// true if this circle and the specified object are equal; otherwise, false. + public override readonly bool Equals(object obj) => obj is Circle other && Equals(other); + + /// + /// Returns a value that indicates whether this circle and the specified circle are equal. + /// + /// The circle to compare with this circle. + /// true if this circle and the specified circle are equal; otherwise, false. + public readonly bool Equals(Circle other) => this.X == other.X && + this.Y == other.Y && + this.Radius == other.Radius; + #endregion + + #region methods_hashcode + /// + /// Returns the hash code for this circle. + /// + /// The hash code for this circle as a 32-bit signed integer. + public override readonly int GetHashCode() => HashCode.Combine(X, Y, Radius); + #endregion + + #region methods_operators + /// + /// Returns a value that indicates if the circle on the left hand side of the equality operator is equal to the + /// circle on the right hand side of the equality operator. + /// + /// The circle on the left hand side of the equality operator. + /// The circle on the right hand side of the equality operator. + /// true if the two circles are equal; otherwise, false. + public static bool operator ==(Circle lhs, Circle rhs) => lhs.Equals(rhs); + + /// + /// Returns a value that indicates if the circle on the left hand side of the inequality operator is not equal to the + /// circle on the right hand side of the inequality operator. + /// + /// The circle on the left hand side of the inequality operator. + /// The circle on the right hand side fo the inequality operator. + /// true if the two circle are not equal; otherwise, false. + public static bool operator !=(Circle lhs, Circle rhs) => !lhs.Equals(rhs); + #endregion +} diff --git a/articles/tutorials/building_2d_games/12_collision_detection/snippets/contains_example.cs b/articles/tutorials/building_2d_games/12_collision_detection/snippets/contains_example.cs new file mode 100644 index 00000000..9bec3770 --- /dev/null +++ b/articles/tutorials/building_2d_games/12_collision_detection/snippets/contains_example.cs @@ -0,0 +1,31 @@ +// Store the current location +Vector2 previousLocation = _spriteLocation; + +// Calculate a new location +Vector2 newLocation = _spriteLocation + new Vector2(10, 0); + +// Create a bounding box for the sprite object +Rectangle spriteBounds = new Rectangle( + (int)newLocation.X, + (int)newLocation.Y, + (int)_sprite.Width, + (int)_sprite.Height +); + +// Get the bounds of the screen as a rectangle +Rectangle screenBounds = new Rectangle( + 0, + 0, + GraphicsDevice.PresentationParameters.BackBufferWidth, + GraphicsDevice.PresentationParameters.BackBufferHeight +); + +// Detect if the sprite is contained within the bounds of the screen +if(!screenBounds.Contains(spriteBounds)) +{ + // Respond by not allowing the sprite to move to move outside the screen + // bounds by setting the location back to the previous location. + newLocation = previousLocation; +} + +_spriteLocation = newLocation; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/12_collision_detection/snippets/game1.cs b/articles/tutorials/building_2d_games/12_collision_detection/snippets/game1.cs new file mode 100644 index 00000000..7a85b431 --- /dev/null +++ b/articles/tutorials/building_2d_games/12_collision_detection/snippets/game1.cs @@ -0,0 +1,304 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; +using MonoGameLibrary.Input; + +namespace DungeonSlime; + +public class Game1 : Core +{ + // Defines the slime animated sprite. + private AnimatedSprite _slime; + + // Defines the bat animated sprite. + private AnimatedSprite _bat; + + // Tracks the position of the slime. + private Vector2 _slimePosition; + + // Speed multiplier when moving. + private const float MOVEMENT_SPEED = 5.0f; + + // Tracks the position of the bat. + private Vector2 _batPosition; + + // Tracks the velocity of the bat. + private Vector2 _batVelocity; + + public Game1() : base("Dungeon Slime", 1280, 720, false) + { + + } + + protected override void Initialize() + { + base.Initialize(); + + // Set the initial position of the bat to be 10px + // to the right of the slime. + _batPosition = new Vector2(_slime.Width + 10, 0); + + // Assign the initial random velocity to the bat. + AssignRandomBatVelocity(); + } + + protected override void LoadContent() + { + // Create the texture atlas from the XML configuration file + TextureAtlas atlas = TextureAtlas.FromFile(Content, "images/atlas-definition.xml"); + + // Create the slime animated sprite from the atlas. + _slime = atlas.CreateAnimatedSprite("slime-animation"); + _slime.Scale = new Vector2(4.0f, 4.0f); + + // Create the bat animated sprite from the atlas. + _bat = atlas.CreateAnimatedSprite("bat-animation"); + _bat.Scale = new Vector2(4.0f, 4.0f); + } + + protected override void Update(GameTime gameTime) + { + if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape)) + Exit(); + + // Update the slime animated sprite. + _slime.Update(gameTime); + + // Update the bat animated sprite. + _bat.Update(gameTime); + + // Check for keyboard input and handle it. + CheckKeyboardInput(); + + // Check for gamepad input and handle it. + CheckGamePadInput(); + + // Create a bounding rectangle for the screen + Rectangle screenBounds = new Rectangle( + 0, + 0, + GraphicsDevice.PresentationParameters.BackBufferWidth, + GraphicsDevice.PresentationParameters.BackBufferHeight + ); + + // Creating a bounding circle for the slime + Circle slimeBounds = new Circle( + (int)(_slimePosition.X + (_slime.Width * 0.5f)), + (int)(_slimePosition.Y + (_slime.Height * 0.5f)), + (int)(_slime.Width * 0.5f) + ); + + // Use distance based checks to determine if the slime is within the + // bounds of the game screen, and if it is outside that screen edge, + // move it back inside. + if (slimeBounds.Left < screenBounds.Left) + { + _slimePosition.X = screenBounds.Left; + } + else if (slimeBounds.Right > screenBounds.Right) + { + _slimePosition.X = screenBounds.Right - _slime.Width; + } + + if (slimeBounds.Top < screenBounds.Top) + { + _slimePosition.Y = screenBounds.Top; + } + else if (slimeBounds.Bottom > screenBounds.Bottom) + { + _slimePosition.Y = screenBounds.Bottom - _slime.Height; + } + + // Calculate the new position of the bat based on the velocity + Vector2 newBatPosition = _batPosition + _batVelocity; + + // Create a bounding circle for the bat + Circle batBounds = new Circle( + (int)(newBatPosition.X + (_bat.Width * 0.5f)), + (int)(newBatPosition.Y + (_bat.Height * 0.5f)), + (int)(_bat.Width * 0.5f) + ); + + Vector2 normal = Vector2.Zero; + + // Use distance based checks to determine if the bat is within the + // bounds of the game screen, and if it is outside that screen edge, + // reflect it about the screen edge normal + if (batBounds.Left < screenBounds.Left) + { + normal.X = Vector2.UnitX.X; + newBatPosition.X = screenBounds.Left; + } + else if (batBounds.Right > screenBounds.Right) + { + normal.X = -Vector2.UnitX.X; + newBatPosition.X = screenBounds.Right - _bat.Width; + } + + if (batBounds.Top < screenBounds.Top) + { + normal.Y = Vector2.UnitY.Y; + newBatPosition.Y = screenBounds.Top; + } + else if (batBounds.Bottom > screenBounds.Bottom) + { + normal.Y = -Vector2.UnitY.Y; + newBatPosition.Y = screenBounds.Bottom - _bat.Height; + } + + // If the normal is anything but Vector2.Zero, this means the bat had + // moved outside the screen edge so we should reflect it about the + // normal. + if (normal != Vector2.Zero) + { + _batVelocity = Vector2.Reflect(_batVelocity, normal); + } + + _batPosition = newBatPosition; + + if (slimeBounds.Intersects(batBounds)) + { + // Divide the width and height of the screen into equal columns and + // rows based on the width and height of the bat. + int totalColumns = GraphicsDevice.PresentationParameters.BackBufferWidth / (int)_bat.Width; + int totalRows = GraphicsDevice.PresentationParameters.BackBufferHeight / (int)_bat.Height; + + // Choose a random row and column based on the total number of each + int column = Random.Shared.Next(0, totalColumns); + int row = Random.Shared.Next(0, totalRows); + + // Change the bat position by setting the x and y values equal to + // the column and row multiplied by the width and height. + _batPosition = new Vector2(column * _bat.Width, row * _bat.Height); + + // Assign a new random velocity to the bat + AssignRandomBatVelocity(); + } + + base.Update(gameTime); + } + + private void AssignRandomBatVelocity() + { + // Generate a random angle + float angle = (float)(Random.Shared.NextDouble() * Math.PI * 2); + + // Convert angle to a direction vector + float x = (float)Math.Cos(angle); + float y = (float)Math.Sin(angle); + Vector2 direction = new Vector2(x, y); + + // Multiply the direction vector by the movement speed + _batVelocity = direction * MOVEMENT_SPEED; + } + + private void CheckKeyboardInput() + { + // If the space key is held down, the movement speed increases by 1.5 + float speed = MOVEMENT_SPEED; + if (Input.Keyboard.IsKeyDown(Keys.Space)) + { + speed *= 1.5f; + } + + // If the W or Up keys are down, move the slime up on the screen. + if (Input.Keyboard.IsKeyDown(Keys.W) || Input.Keyboard.IsKeyDown(Keys.Up)) + { + _slimePosition.Y -= speed; + } + + // if the S or Down keys are down, move the slime down on the screen. + if (Input.Keyboard.IsKeyDown(Keys.S) || Input.Keyboard.IsKeyDown(Keys.Down)) + { + _slimePosition.Y += speed; + } + + // If the A or Left keys are down, move the slime left on the screen. + if (Input.Keyboard.IsKeyDown(Keys.A) || Input.Keyboard.IsKeyDown(Keys.Left)) + { + _slimePosition.X -= speed; + } + + // If the D or Right keys are down, move the slime right on the screen. + if (Input.Keyboard.IsKeyDown(Keys.D) || Input.Keyboard.IsKeyDown(Keys.Right)) + { + _slimePosition.X += speed; + } + } + + private void CheckGamePadInput() + { + GamePadInfo gamePadOne = Input.GamePads[(int)PlayerIndex.One]; + + // If the A button is held down, the movement speed increases by 1.5 + // and the gamepad vibrates as feedback to the player. + float speed = MOVEMENT_SPEED; + if (gamePadOne.IsButtonDown(Buttons.A)) + { + speed *= 1.5f; + GamePad.SetVibration(PlayerIndex.One, 1.0f, 1.0f); + } + else + { + GamePad.SetVibration(PlayerIndex.One, 0.0f, 0.0f); + } + + // Check thumbstick first since it has priority over which gamepad input + // is movement. It has priority since the thumbstick values provide a + // more granular analog value that can be used for movement. + if (gamePadOne.LeftThumbStick != Vector2.Zero) + { + _slimePosition.X += gamePadOne.LeftThumbStick.X * speed; + _slimePosition.Y -= gamePadOne.LeftThumbStick.Y * speed; + } + else + { + // If DPadUp is down, move the slime up on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadUp)) + { + _slimePosition.Y -= speed; + } + + // If DPadDown is down, move the slime down on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadDown)) + { + _slimePosition.Y += speed; + } + + // If DPapLeft is down, move the slime left on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadLeft)) + { + _slimePosition.X -= speed; + } + + // If DPadRight is down, move the slime right on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadRight)) + { + _slimePosition.X += speed; + } + } + } + + protected override void Draw(GameTime gameTime) + { + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + // Draw the slime sprite. + _slime.Draw(SpriteBatch, _slimePosition); + + // Draw the bat sprite. + _bat.Draw(SpriteBatch, _batPosition); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/12_collision_detection/snippets/rectangle_intersects.cs b/articles/tutorials/building_2d_games/12_collision_detection/snippets/rectangle_intersects.cs new file mode 100644 index 00000000..421d44ae --- /dev/null +++ b/articles/tutorials/building_2d_games/12_collision_detection/snippets/rectangle_intersects.cs @@ -0,0 +1,29 @@ +// Rectangle 1 +// Top: 0 +// ---------------- +// | | +// | | +// Left: 0 | | Right: 32 +// | | +// | | +// ---------------- +// Bottom: 32 +Rectangle rect1 = new Rectangle(0, 0, 32, 32); + +// Rectangle 2 +// Top: 16 +// ---------------- +// | | +// | | +// Left: 16 | | Right: 48 +// | | +// | | +// ---------------- +// Bottom: 48 +Rectangle rect2 = new Rectangle (16, 16, 32, 32); + +// rect1.Left (0) < rect2.Right (48) = true +// rect1.Right (32) > rect3.Left (16) = true +// rect1.Top (0) < rect2.Bottom (48) = true +// rect1.Bottom (32) > rect2.Top (16) = true +bool isColliding = rect1.Intersects(rect2); // returns true \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/12_collision_detection/snippets/trigger_example.cs b/articles/tutorials/building_2d_games/12_collision_detection/snippets/trigger_example.cs new file mode 100644 index 00000000..0d65cb6e --- /dev/null +++ b/articles/tutorials/building_2d_games/12_collision_detection/snippets/trigger_example.cs @@ -0,0 +1,14 @@ +// Create a bounding box for the sprite object +Rectangle spriteBounds = new Rectangle( + (int)_spriteLocation.X, + (int)_spriteLocation.Y, + (int)_sprite.Width, + (int)_sprite.Height +); + +// Detect if the sprite object is within the trigger zone +if(_spriteBounds.Intersects(_triggerBounds)) +{ + // Perform some event + CollectItem(); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/12_collision_detection/snippets/vector2_distance.cs b/articles/tutorials/building_2d_games/12_collision_detection/snippets/vector2_distance.cs new file mode 100644 index 00000000..69f87b1b --- /dev/null +++ b/articles/tutorials/building_2d_games/12_collision_detection/snippets/vector2_distance.cs @@ -0,0 +1,22 @@ +Vector2 circle1Position = new Vector2(8, 10); +Vector2 circle2Position = new Vector2(5, 6); + +float circle1Radius = 5; +float circle2Radius = 5; + +// c^2 = (8 - 5)^2 + (10 - 6)^2 +// c^2 = 3^2 + 4^2 +// c^2 = 9 + 16 +// c^2 = 25 +float distanceSquared = Vector2.DistanceSquared(circle1Position, circle2Position); + +// r^2 = (5 + 5)^2 +// r^2 = (10)^2 +// r^2 = 100 +int radiiSquared = (circle1Radius + circle2Radius) * (circle1Radius + circle2Radius) + +// They do not overlap since 100 is not less than 25 +if(radii < distanceSquared) +{ + +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/12_collision_detection/videos/gameplay.webm b/articles/tutorials/building_2d_games/12_collision_detection/videos/gameplay.webm new file mode 100644 index 00000000..126a7196 Binary files /dev/null and b/articles/tutorials/building_2d_games/12_collision_detection/videos/gameplay.webm differ diff --git a/articles/tutorials/building_2d_games/13_working_with_tilemaps/images/atlas.png b/articles/tutorials/building_2d_games/13_working_with_tilemaps/images/atlas.png new file mode 100644 index 00000000..ce804206 Binary files /dev/null and b/articles/tutorials/building_2d_games/13_working_with_tilemaps/images/atlas.png differ diff --git a/articles/tutorials/building_2d_games/13_working_with_tilemaps/images/mgcb-editor.png b/articles/tutorials/building_2d_games/13_working_with_tilemaps/images/mgcb-editor.png new file mode 100644 index 00000000..f3182e7c Binary files /dev/null and b/articles/tutorials/building_2d_games/13_working_with_tilemaps/images/mgcb-editor.png differ diff --git a/articles/tutorials/building_2d_games/13_working_with_tilemaps/images/tileset-grid-comparison.png b/articles/tutorials/building_2d_games/13_working_with_tilemaps/images/tileset-grid-comparison.png new file mode 100644 index 00000000..0ea32089 Binary files /dev/null and b/articles/tutorials/building_2d_games/13_working_with_tilemaps/images/tileset-grid-comparison.png differ diff --git a/articles/tutorials/building_2d_games/13_working_with_tilemaps/images/tileset-to-tilemap-example.png b/articles/tutorials/building_2d_games/13_working_with_tilemaps/images/tileset-to-tilemap-example.png new file mode 100644 index 00000000..a489824f Binary files /dev/null and b/articles/tutorials/building_2d_games/13_working_with_tilemaps/images/tileset-to-tilemap-example.png differ diff --git a/articles/tutorials/building_2d_games/13_working_with_tilemaps/index.md b/articles/tutorials/building_2d_games/13_working_with_tilemaps/index.md new file mode 100644 index 00000000..29168949 --- /dev/null +++ b/articles/tutorials/building_2d_games/13_working_with_tilemaps/index.md @@ -0,0 +1,260 @@ +--- +title: "Chapter 13: Working with Tilemaps" +description: "Learn how to implement tile-based game environments using tilemaps and tilesets, including creating reusable classes for managing tiles and loading level designs from XML configuration files." +--- + +In the previous chapters, you have learned how to draw individual sprites and animated sprites from a texture atlas and handle collision detection. However, the game so far is lacking an actual world or environment to exist in; it is just sprites on a cornflower blue background. Most 2D games feature game worlds built from many tiles arranged in a grid-like patten. These *tilemaps* allow you to efficiently create large game environments without managing thousands of individual sprites. + +In this chapter you will: + +- Learn what tilemaps are and how they are used in game development. +- Create a `Tileset` class to manage collections of related tiles. +- Build a `Tilemap` class to render tile-based game worlds. +- Implement an XML-based tilemap loading system. +- Update our game to use tilemaps for the game environment. + +## Understanding Tilemaps + +Tilemaps are a common technique used in 2D game development to create game worlds. Instead of positioning individual sprites for each element in the game world, a tilemap divides the world into a grid and places tiles from a *tileset* at each grid position. + +### What is a Tileset? + +A tileset is a collection of small images (tiles) that can be combined and arranged to create game environments. Typically these are stored in a single texture atlas, similar to how we have been handing sprites and animations. Common examples of tiles might include: + +- Floor and ground tiles. +- Walls and obstacle tiles. +- Decorative elements like plants and furniture. +- Special tiles like doors, ladders, or water. + +Each tile in a tileset is assigned an ID number, which the tilemap uses to reference which tile goes where. For example, in *Figure 13-1* below, the tileset we will add to our game in a moment is shown on the left and on the right is the same tileset with an overlay showing how each tile is assigned an ID number. + +| ![Figure 13-1: Left: Original dungeon tileset. Right: The same tileset with an overlay showing how each tile is assigned a numeric ID](./images/tileset-grid-comparison.png) | +|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------:| +| **Figure 13-1: Left: Original dungeon tileset. Right: The same tileset with an overlay showing how each tile is assigned a numeric ID** | + +### What is a Tilemap? + +A tilemap is a grid-based data structure that defines while tiles from a tileset appear at each position in the game world. The tilemap stores an ID for each cell in the grid, where the ID corresponds to a specific tile in the tileset. + +For example, a simple tilemap may look like this conceptually: + +```text +00 01 02 01 03 +04 05 06 05 07 +08 09 10 09 11 +04 09 09 09 07 +12 13 14 13 15 +``` + +If we took the above tilemap data and mapped each cell to the tile in the related tileset, it would look something similar to *Figure 13-2* below: + +| ![Figure 13-2: From tileset to tilemap. Left: Tileset with an overlay showing the tile IDs. Right: The tilemap created using the tiles arranged with the pattern from the code example above](./images/tileset-to-tilemap-example.png) | +|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:| +| **Figure 13-2: From tileset to tilemap. Left: Tileset with an overlay showing the tile IDs. Right: The tilemap created using the tiles arranged with the pattern from the code example above** | + +This approach offers several advantage: + +1. **Memory efficiency**: Instead of storing complete information about each tile's appearance, you only need to store a reference ID. +2. **Performance**: Drawing a tilemap can be optimized to reduce texture swapping compared to rendering many individual sprites. +3. **Design flexibility**: Tilemaps make it easy to create, modify, and load level designs from external files. + +We will now take this concept and implement it in our game by creating a `Tileset` class and a `Tilemap` class. + +## The Tileset Class + +The `Tileset` class will manage a collection of tiles from a texture atlas. Each tile will be represented as a `TextureRegion`, building on the tools in the library we created earlier. + +In the `Graphics` folder of the *MonoGameLibrary* project, create a new file named `Tileset.cs` with the following code as the initial structure: + +[!code-csharp[](./snippets/tileset.cs#declaration)] + +### Tileset Properties and Fields + +The `Tileset` class needs to store a `TextureRegion` for each of the individual tiles in the tile set and provide the dimensions (with and height) of the tiles. It should also offers additional properties that provide the total number of rows and columns in the tileset and the total number of tiles. Add the following fields and properties: + +[!code-csharp[](./snippets/tileset.cs#properties)] + +### Tileset Constructor + +The `Tileset` class constructor requires a source `TextureRegion` that represents the tileset and the width and height of the tiles. Based on these parameters provided, it can automatically divide the source `TextureRegion` into a grid of smaller texture regions and calculate the total number of rows, columns, and tiles. + +Add the following constructor: + +[!code-csharp[](./snippets/tileset.cs#ctors)] + +### Tileset Methods + +The `Tileset` class needs to provide methods to retrieve the `TextureRegion` of a tile based on the index (tile ID) or by the location (row and column) of the tile in the tileset. Add the following methods: + +[!code-csharp[](./snippets/tileset.cs#methods)] + +## The Tilemap Class + +Now that we have a `Tileset` class to define our tile collection, we need a `Tilemap` class to arrange these tiles into a game level. The `Tilemap` class will store which tile goes where in our game world and provide methods to draw the entire map. + +In the `Graphics` folder of the *MonoGameLibrary* project, create a new file named `Tilemap.cs` with the following code as the initial structure: + +[!code-csharp[](./snippets/tilemap.cs#declaration)] + +### Tilemap Properties and Fields + +The `Tilemap` class needs to store a reference to the tileset being used, along with an array of the tile IDs representing each tile in the map. It should also offer additional properties that provide the total number of rows and columns are in the tilemap and the total number of tiles. Add the following fields and properties: + +[!code-csharp[](./snippets/tilemap.cs#properties)] + +### Tilemap Constructor + +The `Tilemap` constructor requires the `Tilemap` to reference for each tile, the total number of columns and rows in the map, and the size (width and height) of each tile. + +Add the following constructor: + +[!code-csharp[](./snippets/tilemap.cs#ctors)] + +### Tilemap Tile Management Methods + +The `Tilemap` class should provide methods to set and retrieve tiles, either by index or location (rows and column). Add the following methods: + +[!code-csharp[](./snippets/tilemap.cs#tile-management)] + +### Tilemap Draw Method + +The `Tilemap` class needs a method to draw the tilemap by iterating through each of the tiles and drawing the `TextureRegion` for that tile at its correct position. Add the following method: + +[!code-csharp[](./snippets/tilemap.cs#draw)] + +### Tilemap FromFile Method + +The `Tilemap` class also requires a method to load and create an instance of the tilemap from an external configuration file. This allows us to separate level design from code. Add the following method: + +[!code-csharp[](./snippets/tilemap.cs#from-file)] + +## Updating the Game + +Now that we have the `Tilemap` and `Tileset` classes defined, we can update our game to use them. We will need to + +1. Update the texture atlas to include the tileset. +2. Create a tilemap xml configuration file. +3. Update the game to load the tilemap from the configuration file and draw it. + +### Update the Texture Atlas + +Currently, the texture atlas we have been using only contains the sprites for the slime and bat animations. We need update it to a new version that contains the tileset as well. + +Right-click the following image and save it as `atlas.png` in the `Content/images` directory of the *DungeonSlime* project (your main game project), overwriting the existing one. + +> [!NOTE] +> You do not need to do this in the MGCB editor as you are simply replacing the file and not altering any of its import properties. + +| ![Figure 13-3: The texture atlas for our game updated to include the tileset for the tilemap](./images/atlas.png) | +|:-----------------------------------------------------------------------------------------------------------------:| +| **Figure 13-3: The texture atlas for our game updated to include the tileset for the tilemap** | + +> [!NOTE] +> Since the slime and bat sprites are in the same position in the new texture atlas, we do not need to update the atlas XML configuration file. + +## Creating a Tilemap XML Configuration + +Now that we have the texture atlas updated to include the tileset, we need to create a tilemap configuration that our game can load. The configuration will be an XML file that specifies the tileset to use and the arrangement of tiles in the tilemap. + +We need to add this configuration file to our content project in the *Content/images* folder with the MGCB Editor, in the same way we did with the "atlas-definition.xml": + +1. Open the *Content.mgcb* content project file in the MGCB Editor. +2. Right-click the *images* folder and choose *Add > New Item...*. +3. Select the *Xml Content (.xml)* type and name it `tilemap-definition` +4. Select the `tilemap-definition.xml` file you just created. +5. In the Properties panel, change the *Build Action* property from *Build* to *Copy*. +6. Save the changes in the MGCB Editor. + +| ![Figure 13-4: The Content project in the MGCB Editor with the tilemap-definition.xml file added and the Build Action property set to copy](./images/mgcb-editor.png) | +|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------:| +| **Figure 13-4: The Content project in the MGCB Editor with the tilemap-definition.xml file added and the Build Action property set to copy** | + +7. Open the `tilemap-definition.xml` file in your code editor and replace its contents with the following and save it: + +[!code-xml[](./snippets/tilemap-definition.xml)] + +This tilemap configuration creates a simple dungeon layout with walls around the perimeter and an open floor in the middle. The tile IDs correspond to specific tiles in the tileset: + +- `00`, `03`, `12`, `15`: Corner wall tiles (top-left, top-right, bottom-left, bottom-right). +- `01`, `02`, `13`, `14`: Horizontal wall tiles (top and bottom walls). +- `04`, `07`, `08`, `11`: Vertical wall tiles (left and right walls). +- `05` and `06`: Top floor edge tiles. +- `09`: Standard floor tile. +- `10`: Decorated floor tile with a crack in it. + +### Update the Game1 Class + +With all of the assets now in place and configured, we can update the `Game1` class to load the tilemap and draw it. We will also need to update the collision logic so that the boundary is no longer the edge of the screen, but instead the edges of the wall tiles of the tilemap. Open `Game1.cs` and make the following updates: + +[!code-csharp[](./snippets/game1.cs?highlight=31-35,46-61,80-82,112,114,116,128,121,123,125,127,145,148,150,153,156,159,161,164,179-181,303-304)] + +The key changes to the `Game1` class include: + +1. The `_tilemap` field was added to hold the loaded tilemap. +2. The `_roombounds` [**Rectangle**](xref:Microsoft.Xna.Framework.Rectangle) was added to define the playable area within the tilemap to keep the slime and bat inside the walls. +3. In [**Initialize**](xref:Microsoft.Xna.Framework.Game.Initialize): + 1. The `_roomBounds` is set based on the tilemap's tile size. + 2. The starting position of the slime is now set to be in the center of the room. +4. In [**LoadContent**](xref:Microsoft.Xna.Framework.Game.LoadContent): + 1. The tilemap is loaded from the XML configuration file. + 2. The scale of the tilemap is set to a factor of 4.0. +5. In [**Update**](xref:Microsoft.Xna.Framework.Game.Update(Microsoft.Xna.Framework.GameTime)), the `screenBounds` variable was removed and the collision logic has been updated to instead use the `_roomBounds` instead. +6. In [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)) the tilemap is drawn. + +Running the game now with these changes, our game now visually transforms from a simple screen with sprites to a proper game environment with walls and floors. The slime and bat are now confined within the walls of the dungeon defined by our tilemap. + +| ![Figure 13-5: Gameplay with the tilemap rendered and the bat and slime contained within the dungeon walls](./videos/gameplay.webm) | +|:-----------------------------------------------------------------------------------------------------------------------------------:| +| **Figure 13-5: Gameplay with the tilemap rendered and the bat and slime contained within the dungeon walls** | + +## Additional Notes + +While the method provided in this chapter offers a straightforward approach to loading tilemaps from external configuration files, several dedicated tools exist specifically for creating tilemaps for games. Popular options include [Tiled](https://www.mapeditor.org/), [LDtk](https://ldtk.io/), and [Ogmo](https://ogmo-editor-3.github.io/). These specialized tools export map configurations in various formats such as XML (similar to what we implemented) or JSON, and often include additional features like multiple layers, object placement, and custom properties for tiles. + +> [!NOTE] +> In order to utilize Tilemaps from other tools, you will need a way to import those maps as MonoGame does not natively support them, one option is to use [MonoGame.Extended](https://www.monogameextended.net/) which provides Tilemap importers for Tiled (and more in the future) as well as a host of other features. + +Although these tools are more robust than our implementation, the underlying concept remains the same: a tilemap is fundamentally a grid layout where each cell references a tile ID from a tileset. The principles you have learned in this chapter form the foundation for working with any tilemap system, regardless of which tool you might use. + +## Conclusion + +In this chapter, you accomplished the following: + +- Learned about tilemaps and how they are used in 2D game development. +- Created a `Tileset` class to manage collections of tiles from a texture atlas. +- Implemented a `Tilemap` class to render grid-based game environments. +- Created an XML-based tilemap definition system for storing level layouts. +- Updated our game to use tilemaps for the game environment. + +In the next chapter, we will start exploring audio to add sound effects when a collision occurs and background music to our game. + +## Test Your Knowledge + +1. What is the main advantage of using tilemaps for game environments rather than individual sprites? + + :::question-answer + Tilemaps offer several advantages: memory efficiency (reusing tiles instead of storing complete environments), performance optimization (batched rendering), and design flexibility (easier to create and modify levels). They allow creating large game worlds by reusing a small set of tiles in different arrangements. + ::: + +2. What is the relationship between a tileset and a tilemap? + + :::question-answer + A tileset is a collection of individual tiles stored in a texture atlas, where each tile has a unique ID. A tilemap is a grid-based structure that references tiles from the tileset by their IDs to create a complete game environment. The tileset provides the visual elements, while the tilemap defines their arrangement. + ::: + +3. Why might you use an XML definition for a tilemap instead of hardcoding the tile layout? + + :::question-answer + Using XML definitions for tilemaps separates level design from game code, offering several benefits: easier level editing (without changing code), support for multiple levels, ability to create external level editors, and better organization of game content. It also allows non-programmers like game designers to create and modify levels. + ::: + +4. In our implementation, how does the Tilemap's Draw method work? + + :::question-answer + The Tilemap's Draw method iterates through each position in the grid. For each position, it: + + 1. Retrieves the tile ID stored at that position. + 2. Gets the corresponding texture region from the tileset. + 3. Calculates the screen position based on the grid coordinates and tile size. + 4. Draws the texture region at that position using the sprite batch. + ::: diff --git a/articles/tutorials/building_2d_games/13_working_with_tilemaps/snippets/atlas-definition.xml b/articles/tutorials/building_2d_games/13_working_with_tilemaps/snippets/atlas-definition.xml new file mode 100644 index 00000000..5532e660 --- /dev/null +++ b/articles/tutorials/building_2d_games/13_working_with_tilemaps/snippets/atlas-definition.xml @@ -0,0 +1,23 @@ + + + images/atlas + + + + + + + + + + + + + + + + + + + + diff --git a/articles/tutorials/building_2d_games/13_working_with_tilemaps/snippets/game1.cs b/articles/tutorials/building_2d_games/13_working_with_tilemaps/snippets/game1.cs new file mode 100644 index 00000000..adbd0a34 --- /dev/null +++ b/articles/tutorials/building_2d_games/13_working_with_tilemaps/snippets/game1.cs @@ -0,0 +1,317 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; +using MonoGameLibrary.Input; + +namespace DungeonSlime; + +public class Game1 : Core +{ + // Defines the slime animated sprite. + private AnimatedSprite _slime; + + // Defines the bat animated sprite. + private AnimatedSprite _bat; + + // Tracks the position of the slime. + private Vector2 _slimePosition; + + // Speed multiplier when moving. + private const float MOVEMENT_SPEED = 5.0f; + + // Tracks the position of the bat. + private Vector2 _batPosition; + + // Tracks the velocity of the bat. + private Vector2 _batVelocity; + + // Defines the tilemap to draw. + private Tilemap _tilemap; + + // Defines the bounds of the room that the slime and bat are contained within. + private Rectangle _roomBounds; + + public Game1() : base("Dungeon Slime", 1280, 720, false) + { + + } + + protected override void Initialize() + { + base.Initialize(); + + Rectangle screenBounds = GraphicsDevice.PresentationParameters.Bounds; + + _roomBounds = new Rectangle( + (int)_tilemap.TileWidth, + (int)_tilemap.TileHeight, + screenBounds.Width - (int)_tilemap.TileWidth * 2, + screenBounds.Height - (int)_tilemap.TileHeight * 2 + ); + + // Initial slime position will be the center tile of the tile map. + int centerRow = _tilemap.Rows / 2; + int centerColumn = _tilemap.Columns / 2; + _slimePosition = new Vector2(centerColumn * _tilemap.TileWidth, centerRow * _tilemap.TileHeight); + + // Initial bat position will the in the top left corner of the room + _batPosition = new Vector2(_roomBounds.Left, _roomBounds.Top); + + // Assign the initial random velocity to the bat. + AssignRandomBatVelocity(); + } + + protected override void LoadContent() + { + // Create the texture atlas from the XML configuration file + TextureAtlas atlas = TextureAtlas.FromFile(Content, "images/atlas-definition.xml"); + + // Create the slime animated sprite from the atlas. + _slime = atlas.CreateAnimatedSprite("slime-animation"); + _slime.Scale = new Vector2(4.0f, 4.0f); + + // Create the bat animated sprite from the atlas. + _bat = atlas.CreateAnimatedSprite("bat-animation"); + _bat.Scale = new Vector2(4.0f, 4.0f); + + // Create the tilemap from the XML configuration file. + _tilemap = Tilemap.FromFile(Content, "images/tilemap-definition.xml"); + _tilemap.Scale = new Vector2(4.0f, 4.0f); + } + + protected override void Update(GameTime gameTime) + { + if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape)) + Exit(); + + // Update the slime animated sprite. + _slime.Update(gameTime); + + // Update the bat animated sprite. + _bat.Update(gameTime); + + // Check for keyboard input and handle it. + CheckKeyboardInput(); + + // Check for gamepad input and handle it. + CheckGamePadInput(); + + // Creating a bounding circle for the slime + Circle slimeBounds = new Circle( + (int)(_slimePosition.X + (_slime.Width * 0.5f)), + (int)(_slimePosition.Y + (_slime.Height * 0.5f)), + (int)(_slime.Width * 0.5f) + ); + + // Use distance based checks to determine if the slime is within the + // bounds of the game screen, and if it is outside that screen edge, + // move it back inside. + if (slimeBounds.Left < _roomBounds.Left) + { + _slimePosition.X = _roomBounds.Left; + } + else if (slimeBounds.Right > _roomBounds.Right) + { + _slimePosition.X = _roomBounds.Right - _slime.Width; + } + + if (slimeBounds.Top < _roomBounds.Top) + { + _slimePosition.Y = _roomBounds.Top; + } + else if (slimeBounds.Bottom > _roomBounds.Bottom) + { + _slimePosition.Y = _roomBounds.Bottom - _slime.Height; + } + + // Calculate the new position of the bat based on the velocity + Vector2 newBatPosition = _batPosition + _batVelocity; + + // Create a bounding circle for the bat + Circle batBounds = new Circle( + (int)(newBatPosition.X + (_bat.Width * 0.5f)), + (int)(newBatPosition.Y + (_bat.Height * 0.5f)), + (int)(_bat.Width * 0.5f) + ); + + Vector2 normal = Vector2.Zero; + + // Use distance based checks to determine if the bat is within the + // bounds of the game screen, and if it is outside that screen edge, + // reflect it about the screen edge normal + if (batBounds.Left < _roomBounds.Left) + { + normal.X = Vector2.UnitX.X; + newBatPosition.X = _roomBounds.Left; + } + else if (batBounds.Right > _roomBounds.Right) + { + normal.X = -Vector2.UnitX.X; + newBatPosition.X = _roomBounds.Right - _bat.Width; + } + + if (batBounds.Top < _roomBounds.Top) + { + normal.Y = Vector2.UnitY.Y; + newBatPosition.Y = _roomBounds.Top; + } + else if (batBounds.Bottom > _roomBounds.Bottom) + { + normal.Y = -Vector2.UnitY.Y; + newBatPosition.Y = _roomBounds.Bottom - _bat.Height; + } + + // If the normal is anything but Vector2.Zero, this means the bat had + // moved outside the screen edge so we should reflect it about the + // normal. + if (normal != Vector2.Zero) + { + _batVelocity = Vector2.Reflect(_batVelocity, normal); + } + + _batPosition = newBatPosition; + + if (slimeBounds.Intersects(batBounds)) + { + // Choose a random row and column based on the total number of each + int column = Random.Shared.Next(1, _tilemap.Columns - 1); + int row = Random.Shared.Next(1, _tilemap.Rows - 1); + + // Change the bat position by setting the x and y values equal to + // the column and row multiplied by the width and height. + _batPosition = new Vector2(column * _bat.Width, row * _bat.Height); + + // Assign a new random velocity to the bat + AssignRandomBatVelocity(); + } + + base.Update(gameTime); + } + + private void AssignRandomBatVelocity() + { + // Generate a random angle + float angle = (float)(Random.Shared.NextDouble() * Math.PI * 2); + + // Convert angle to a direction vector + float x = (float)Math.Cos(angle); + float y = (float)Math.Sin(angle); + Vector2 direction = new Vector2(x, y); + + // Multiply the direction vector by the movement speed + _batVelocity = direction * MOVEMENT_SPEED; + } + + private void CheckKeyboardInput() + { + // If the space key is held down, the movement speed increases by 1.5 + float speed = MOVEMENT_SPEED; + if (Input.Keyboard.IsKeyDown(Keys.Space)) + { + speed *= 1.5f; + } + + // If the W or Up keys are down, move the slime up on the screen. + if (Input.Keyboard.IsKeyDown(Keys.W) || Input.Keyboard.IsKeyDown(Keys.Up)) + { + _slimePosition.Y -= speed; + } + + // if the S or Down keys are down, move the slime down on the screen. + if (Input.Keyboard.IsKeyDown(Keys.S) || Input.Keyboard.IsKeyDown(Keys.Down)) + { + _slimePosition.Y += speed; + } + + // If the A or Left keys are down, move the slime left on the screen. + if (Input.Keyboard.IsKeyDown(Keys.A) || Input.Keyboard.IsKeyDown(Keys.Left)) + { + _slimePosition.X -= speed; + } + + // If the D or Right keys are down, move the slime right on the screen. + if (Input.Keyboard.IsKeyDown(Keys.D) || Input.Keyboard.IsKeyDown(Keys.Right)) + { + _slimePosition.X += speed; + } + } + + private void CheckGamePadInput() + { + GamePadInfo gamePadOne = Input.GamePads[(int)PlayerIndex.One]; + + // If the A button is held down, the movement speed increases by 1.5 + // and the gamepad vibrates as feedback to the player. + float speed = MOVEMENT_SPEED; + if (gamePadOne.IsButtonDown(Buttons.A)) + { + speed *= 1.5f; + GamePad.SetVibration(PlayerIndex.One, 1.0f, 1.0f); + } + else + { + GamePad.SetVibration(PlayerIndex.One, 0.0f, 0.0f); + } + + // Check thumbstick first since it has priority over which gamepad input + // is movement. It has priority since the thumbstick values provide a + // more granular analog value that can be used for movement. + if (gamePadOne.LeftThumbStick != Vector2.Zero) + { + _slimePosition.X += gamePadOne.LeftThumbStick.X * speed; + _slimePosition.Y -= gamePadOne.LeftThumbStick.Y * speed; + } + else + { + // If DPadUp is down, move the slime up on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadUp)) + { + _slimePosition.Y -= speed; + } + + // If DPadDown is down, move the slime down on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadDown)) + { + _slimePosition.Y += speed; + } + + // If DPapLeft is down, move the slime left on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadLeft)) + { + _slimePosition.X -= speed; + } + + // If DPadRight is down, move the slime right on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadRight)) + { + _slimePosition.X += speed; + } + } + } + + protected override void Draw(GameTime gameTime) + { + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + // Draw the tilemap. + _tilemap.Draw(SpriteBatch); + + // Draw the slime sprite. + _slime.Draw(SpriteBatch, _slimePosition); + + // Draw the bat sprite. + _bat.Draw(SpriteBatch, _batPosition); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/13_working_with_tilemaps/snippets/tilemap-definition.xml b/articles/tutorials/building_2d_games/13_working_with_tilemaps/snippets/tilemap-definition.xml new file mode 100644 index 00000000..f91b6b62 --- /dev/null +++ b/articles/tutorials/building_2d_games/13_working_with_tilemaps/snippets/tilemap-definition.xml @@ -0,0 +1,15 @@ + + + images/atlas + + 00 01 02 01 02 01 02 01 02 01 02 01 02 01 02 03 + 04 05 05 06 05 05 06 05 05 06 05 05 06 05 05 07 + 08 09 09 09 09 09 09 09 09 09 09 09 09 09 09 11 + 04 09 09 09 09 09 09 09 10 09 09 09 09 10 09 07 + 08 09 10 09 09 09 09 09 09 09 09 09 09 09 09 11 + 04 09 09 09 09 09 09 09 09 09 09 09 09 09 09 07 + 08 10 09 09 09 09 09 09 09 09 10 09 09 09 09 11 + 04 09 09 09 09 09 10 09 09 09 09 09 09 09 09 07 + 12 13 14 13 14 13 14 13 14 13 14 13 14 13 14 15 + + diff --git a/articles/tutorials/building_2d_games/13_working_with_tilemaps/snippets/tilemap.cs b/articles/tutorials/building_2d_games/13_working_with_tilemaps/snippets/tilemap.cs new file mode 100644 index 00000000..4b9b7fcb --- /dev/null +++ b/articles/tutorials/building_2d_games/13_working_with_tilemaps/snippets/tilemap.cs @@ -0,0 +1,246 @@ +#region declaration +using System; +using System.IO; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; + +namespace MonoGameLibrary.Graphics; + +public class Tilemap +{ + +} +#endregion +{ + #region properties + private readonly Tileset _tileset; + private readonly int[] _tiles; + + /// + /// Gets the total number of rows in this tilemap. + /// + public int Rows { get; } + + /// + /// Gets the total number of columns in this tilemap. + /// + public int Columns { get; } + + /// + /// Gets the total number of tiles in this tilemap. + /// + public int Count { get; } + + /// + /// Gets or Sets the scale factor to draw each tile at. + /// + public Vector2 Scale { get; set; } + + /// + /// Gets the width, in pixels, each tile is drawn at. + /// + public float TileWidth => _tileset.TileWidth * Scale.X; + + /// + /// Gets the height, in pixels, each tile is drawn at. + /// + public float TileHeight => _tileset.TileHeight * Scale.Y; + #endregion + + #region ctors + /// + /// Creates a new tilemap. + /// + /// The tileset used by this tilemap. + /// The total number of columns in this tilemap. + /// The total number of rows in this tilemap. + public Tilemap(Tileset tileset, int columns, int rows) + { + _tileset = tileset; + Rows = rows; + Columns = columns; + Count = Columns * Rows; + Scale = Vector2.One; + _tiles = new int[Count]; + } + #endregion + + #region tile-management + /// + /// Sets the tile at the given index in this tilemap to use the tile from + /// the tileset at the specified tileset id. + /// + /// The index of the tile in this tilemap. + /// The tileset id of the tile from the tileset to use. + public void SetTile(int index, int tilesetID) + { + _tiles[index] = tilesetID; + } + + /// + /// Sets the tile at the given column and row in this tilemap to use the tile + /// from the tileset at the specified tileset id. + /// + /// The column of the tile in this tilemap. + /// The row of the tile in this tilemap. + /// The tileset id of the tile from the tileset to use. + public void SetTile(int column, int row, int tilesetID) + { + int index = row * Columns + column; + SetTile(index, tilesetID); + } + + /// + /// Gets the texture region of the tile from this tilemap at the specified index. + /// + /// The index of the tile in this tilemap. + /// The texture region of the tile from this tilemap at the specified index. + public TextureRegion GetTile(int index) + { + return _tileset.GetTile(_tiles[index]); + } + + /// + /// Gets the texture region of the tile frm this tilemap at the specified + /// column and row. + /// + /// The column of the tile in this tilemap. + /// The row of hte tile in this tilemap. + /// The texture region of the tile from this tilemap at the specified column and row. + public TextureRegion GetTile(int column, int row) + { + int index = row * Columns + column; + return GetTile(index); + } + #endregion + + #region draw + /// + /// Draws this tilemap using the given sprite batch. + /// + /// The sprite batch used to draw this tilemap. + public void Draw(SpriteBatch spriteBatch) + { + for (int i = 0; i < Count; i++) + { + int tileSetIndex = _tiles[i]; + TextureRegion tile = _tileset.GetTile(tileSetIndex); + + int x = i % Columns; + int y = i / Columns; + + Vector2 position = new Vector2(x * TileWidth, y * TileHeight); + tile.Draw(spriteBatch, position, Color.White, 0.0f, Vector2.Zero, Scale, SpriteEffects.None, 1.0f); + } + } + #endregion + + #region from-file + /// + /// Creates a new tilemap based on a tilemap xml configuration file. + /// + /// The content manager used to load the texture for the tileset. + /// The path to the xml file, relative to the content root directory. + /// The tilemap created by this method. + public static Tilemap FromFile(ContentManager content, string filename) + { + string filePath = Path.Combine(content.RootDirectory, filename); + + using (Stream stream = TitleContainer.OpenStream(filePath)) + { + using (XmlReader reader = XmlReader.Create(stream)) + { + XDocument doc = XDocument.Load(reader); + XElement root = doc.Root; + + // The element contains the information about the tileset + // used by the tilemap. + // + // Example + // contentPath + // + // The region attribute represents the x, y, width, and height + // components of the boundary for the texture region within the + // texture at the contentPath specified. + // + // the tileWidth and tileHeight attributes specify the width and + // height of each tile in the tileset. + // + // the contentPath value is the contentPath to the texture to + // load that contains the tileset + XElement tilesetElement = root.Element("Tileset"); + + string regionAttribute = tilesetElement.Attribute("region").Value; + string[] split = regionAttribute.Split(" ", StringSplitOptions.RemoveEmptyEntries); + int x = int.Parse(split[0]); + int y = int.Parse(split[1]); + int width = int.Parse(split[2]); + int height = int.Parse(split[3]); + + int tileWidth = int.Parse(tilesetElement.Attribute("tileWidth").Value); + int tileHeight = int.Parse(tilesetElement.Attribute("tileHeight").Value); + string contentPath = tilesetElement.Value; + + // Load the texture 2d at the content path + Texture2D texture = content.Load(contentPath); + + // Create the texture region from the texture + TextureRegion textureRegion = new TextureRegion(texture, x, y, width, height); + + // Create the tileset using the texture region + Tileset tileset = new Tileset(textureRegion, tileWidth, tileHeight); + + // The element contains lines of strings where each line + // represents a row in the tilemap. Each line is a space + // separated string where each element represents a column in that + // row. The value of the column is the id of the tile in the + // tileset to draw for that location. + // + // Example: + // + // 00 01 01 02 + // 03 04 04 05 + // 03 04 04 05 + // 06 07 07 08 + // + XElement tilesElement = root.Element("Tiles"); + + // Split the value of the tiles data into rows by splitting on + // the new line character + string[] rows = tilesElement.Value.Trim().Split('\n', StringSplitOptions.RemoveEmptyEntries); + + // Split the value of the first row to determine the total number of columns + int columnCount = rows[0].Split(" ", StringSplitOptions.RemoveEmptyEntries).Length; + + // Create the tilemap + Tilemap tilemap = new Tilemap(tileset, columnCount, rows.Length); + + // Process each row + for (int row = 0; row < rows.Length; row++) + { + // Split the row into individual columns + string[] columns = rows[row].Trim().Split(" ", StringSplitOptions.RemoveEmptyEntries); + + // Process each column of the current row + for (int column = 0; column < columnCount; column++) + { + // Get the tileset index for this location + int tilesetIndex = int.Parse(columns[column]); + + // Get the texture region of that tile from the tileset + TextureRegion region = tileset.GetTile(tilesetIndex); + + // Add that region to the tilemap at the row and column location + tilemap.SetTile(column, row, tilesetIndex); + } + } + + return tilemap; + } + } + } + #endregion +} diff --git a/articles/tutorials/building_2d_games/13_working_with_tilemaps/snippets/tileset.cs b/articles/tutorials/building_2d_games/13_working_with_tilemaps/snippets/tileset.cs new file mode 100644 index 00000000..c4b3d32d --- /dev/null +++ b/articles/tutorials/building_2d_games/13_working_with_tilemaps/snippets/tileset.cs @@ -0,0 +1,87 @@ +#region declaration +namespace MonoGameLibrary.Graphics; + +public class Tileset +{ + +} +#endregion +{ + #region properties + private readonly TextureRegion[] _tiles; + + /// + /// Gets the width, in pixels, of each tile in this tileset. + /// + public int TileWidth { get; } + + /// + /// Gets the height, in pixels, of each tile in this tileset. + /// + public int TileHeight { get; } + + /// + /// Gets the total number of columns in this tileset. + /// + public int Columns { get; } + + /// + /// Gets the total number of rows in this tileset. + /// + public int Rows { get; } + + /// + /// Gets the total number of tiles in this tileset. + /// + public int Count { get; } + #endregion + + #region ctors + /// + /// Creates a new tileset based on the given texture region with the specified + /// tile width and height. + /// + /// The texture region that contains the tiles for the tileset. + /// The width of each tile in the tileset. + /// The height of each tile in the tileset. + public Tileset(TextureRegion textureRegion, int tileWidth, int tileHeight) + { + TileWidth = tileWidth; + TileHeight = tileHeight; + Columns = textureRegion.Width / tileWidth; + Rows = textureRegion.Height / tileHeight; + Count = Columns * Rows; + + // Create the texture regions that make up each individual tile + _tiles = new TextureRegion[Count]; + + for (int i = 0; i < Count; i++) + { + int x = i % Columns * tileWidth; + int y = i / Columns * tileHeight; + _tiles[i] = new TextureRegion(textureRegion.Texture, textureRegion.SourceRectangle.X + x, textureRegion.SourceRectangle.Y + y, tileWidth, tileHeight); + } + } + #endregion + + #region methods + /// + /// Gets the texture region for the tile from this tileset at the given index. + /// + /// The index of the texture region in this tile set. + /// The texture region for the tile form this tileset at the given index. + public TextureRegion GetTile(int index) => _tiles[index]; + + /// + /// Gets the texture region for the tile from this tileset at the given location. + /// + /// The column in this tileset of the texture region. + /// The row in this tileset of the texture region. + /// The texture region for the tile from this tileset at given location. + public TextureRegion GetTile(int column, int row) + { + int index = row * Columns + column; + return GetTile(index); + } + #endregion +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/13_working_with_tilemaps/videos/gameplay.webm b/articles/tutorials/building_2d_games/13_working_with_tilemaps/videos/gameplay.webm new file mode 100644 index 00000000..6b4037c8 Binary files /dev/null and b/articles/tutorials/building_2d_games/13_working_with_tilemaps/videos/gameplay.webm differ diff --git a/articles/tutorials/building_2d_games/14_soundeffects_and_music/files/bounce.wav b/articles/tutorials/building_2d_games/14_soundeffects_and_music/files/bounce.wav new file mode 100644 index 00000000..baa7a47b Binary files /dev/null and b/articles/tutorials/building_2d_games/14_soundeffects_and_music/files/bounce.wav differ diff --git a/articles/tutorials/building_2d_games/14_soundeffects_and_music/files/collect.wav b/articles/tutorials/building_2d_games/14_soundeffects_and_music/files/collect.wav new file mode 100644 index 00000000..506220de Binary files /dev/null and b/articles/tutorials/building_2d_games/14_soundeffects_and_music/files/collect.wav differ diff --git a/articles/tutorials/building_2d_games/14_soundeffects_and_music/files/theme.ogg b/articles/tutorials/building_2d_games/14_soundeffects_and_music/files/theme.ogg new file mode 100644 index 00000000..72e1fd3b Binary files /dev/null and b/articles/tutorials/building_2d_games/14_soundeffects_and_music/files/theme.ogg differ diff --git a/articles/tutorials/building_2d_games/14_soundeffects_and_music/images/song-properties.png b/articles/tutorials/building_2d_games/14_soundeffects_and_music/images/song-properties.png new file mode 100644 index 00000000..87d53631 Binary files /dev/null and b/articles/tutorials/building_2d_games/14_soundeffects_and_music/images/song-properties.png differ diff --git a/articles/tutorials/building_2d_games/14_soundeffects_and_music/images/sound-effect-properties.png b/articles/tutorials/building_2d_games/14_soundeffects_and_music/images/sound-effect-properties.png new file mode 100644 index 00000000..6fe24eed Binary files /dev/null and b/articles/tutorials/building_2d_games/14_soundeffects_and_music/images/sound-effect-properties.png differ diff --git a/articles/tutorials/building_2d_games/14_soundeffects_and_music/images/xact-editor.png b/articles/tutorials/building_2d_games/14_soundeffects_and_music/images/xact-editor.png new file mode 100644 index 00000000..667d6771 Binary files /dev/null and b/articles/tutorials/building_2d_games/14_soundeffects_and_music/images/xact-editor.png differ diff --git a/articles/tutorials/building_2d_games/14_soundeffects_and_music/index.md b/articles/tutorials/building_2d_games/14_soundeffects_and_music/index.md new file mode 100644 index 00000000..5ae662e4 --- /dev/null +++ b/articles/tutorials/building_2d_games/14_soundeffects_and_music/index.md @@ -0,0 +1,257 @@ +--- +title: "Chapter 14: SoundEffects and Music" +description: "Learn how to load and play sound effects and background music in MonoGame including managing audio volume, looping, and handling multiple sound effects at once." +--- + +In [Chapter 12](../12_collision_detection/index.md), we implemented collision detection to enable interactions between game objects; the slime can now "eat" the bat, which respawns in a random location, while the bat bounces off walls of the dungeon. While these mechanics work visually, our game lacks an important element of player feedback: audio. + +Audio plays a crucial role in game development by providing immediate feedback for player actions and creating atmosphere. Sound effects alert players when events occur (like collisions or collecting items), while background music helps establish mood and atmosphere. + +In this chapter, you will: + +- Learn how MonoGame handles different types of audio content. +- Learn how to load and play sound effects and music using the content pipeline. +- Implement sound effects for collision events. +- Add background music to enhance atmosphere. + +We will first start by understanding how MonoGame approaches audio content. + +## Understanding Audio in MonoGame + +Recall from [Chapter 01](../01_what_is_monogame/index.md) that MonoGame is an implementation of the XNA API. With XNA, there were two methods for implementing audio in your game: the *Microsoft Cross-Platform Audio Creation Tool* (XACT) and the simplified sound API. + +> [!IMPORTANT] +> XACT is a mini audio engineering studio where you can easily edit the audio for your game like editing volume, pitch, looping, applying effects, and other properties without having to do it in code. At that time, XACT for XNA games was akin to what FMOD Studio is today for game audio. +> +> | ![Figure 14-1: Microsoft Cross-Platform Audio Creation Tool](./images/xact-editor.png) | +> |:--------------------------------------------------------------------------------------:| +> | **Figure 14-1: Microsoft Cross-Platform Audio Creation Tool** | +> +> While XACT projects are still fully supported in MonoGame, it remains a Windows-only tool that has not been updated since Microsoft discontinued the original XNA, nor has its source code been made open source. Though it is possible to install XACT on modern Windows, the process can be complex. +> +> For these reasons, this tutorial will focus on the simplified sound API, which provides all the core functionality needed for most games while remaining cross-platform compatible. + +The simplified sound API approaches audio management through two distinct paths, each optimized for different use cases in games. When adding audio to your game, you need to consider how different types of sounds should be handled: + +- **Sound Effects**: Short audio clips that need to play immediately and often simultaneously, like the bounce of a ball or feedback for picking up a collectable. +- **Music**: Longer audio pieces that play continuously in the background, like level themes. + +MonoGame addresses these different needs through two main classes: + +### Sound Effects + +The [**SoundEffect**](xref:Microsoft.Xna.Framework.Audio.SoundEffect) class handles short audio clips like: + +- Collision sounds. +- Player action feedback (jumping, shooting, etc.). +- UI interactions (button clicks, menu navigation). +- Environmental effects (footsteps, ambient sounds). + +The key characteristics of sound effects are: + +- Loaded entirely into memory for quick access +- Can play multiple instances simultaneously: + - Mobile platforms can have a maximum of 32 sounds playing simultaneously. + - Desktop platforms have a maximum of 256 sounds playing simultaneously. + - Consoles and other platforms have their own constraints, and you would need to refer to the SDK documentation for that platform. +- Lower latency playback (ideal for immediate feedback) +- Individual volume control per instance. + +### Music + +The [**Song**](xref:Microsoft.Xna.Framework.Media.Song) class handles longer audio pieces like background music. The key characteristics of songs are: + +- Streamed from storage rather than loaded into memory. +- Only one song can be played at a time. +- Higher latency, but lower memory usage. + +Throughout this chapter, we will use both classes to add audio feedback to our game; sound effects for the bat bouncing and being eaten by the slime, and background music to create atmosphere. + +## Loading Audio Content + +Just like textures, audio content in MonoGame can be loaded through the content pipeline, optimizing the format for your target platform. + +### Supported Audio Formats + +MonoGame supports several audio file formats for both sound effects and music: + +- `.wav`: Uncompressed audio, ideal for short sound effects +- `.mp3`: Compressed audio, better for music and longer sounds +- `.ogg`: Open source compressed format, supported on all platforms +- `.wma`: Windows Media Audio format (not recommended for cross-platform games) + +> [!TIP] +> For sound effects, `.wav` files provide the best loading and playback performance since they do not need to be decompressed. For music, `.mp3` or `.ogg` files are better choices as they reduce file size while maintaining good quality. + +### Adding Audio Files + +Adding audio files can be done through the content pipeline, just like we did for image files, using the MGCB Editor. When you add an audio file to the content project, the MGCB Editor will automatically select the appropriate importer and processor for the audio file based on the file extension. + +The processor that are available for audio files file: + +- **Sound Effects**: Processes the audio file as a [**SoundEffect**](xref:Microsoft.Xna.Framework.Audio.SoundEffect). This is automatically selected for *.wav* files. +- **Song**: Processes the audio file as a [**Song**](xref:Microsoft.Xna.Framework.Media.Song). This is automatically selected for *.mp3*, *.ogg*, and *.wma* files. + +| ![Figure 14-2: MGCB Editor properties panel showing Sound Effect content processor settings for .wav files](./images/sound-effect-properties.png) | ![Figure 14-3: MGCB Editor properties panel showing Song content processor settings for .mp3 files](./images/song-properties.png) | +| :-----------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 14-2: MGCB Editor properties panel showing Sound Effect content processor settings for .wav files** | **Figure 14-3: MGCB Editor properties panel showing Song content processor settings for .mp3 files** | + +> [!NOTE] +> While you typically will not need to change the processor it automatically selects, there may be times where you add files, such as *.mp3* files that are meant to be sound effects and not songs. Always double check that the processor selected is for the intended type. + +### Loading Sound Effects + +To load a sound effect, we use [**ContentManager.Load**](xref:Microsoft.Xna.Framework.Content.ContentManager.Load``1(System.String)) with the [**SoundEffect**](xref:Microsoft.Xna.Framework.Audio.SoundEffect) type: + +[!code-csharp[](./snippets/load_soundeffect.cs)] + +### Loading Music + +Loading music is similar, only we specify the [**Song**](xref:Microsoft.Xna.Framework.Media.Song) type instead. + +[!code-csharp[](./snippets/load_song.cs)] + +## Playing Sound Effects + +Sound effects are played using the [**SoundEffect**](xref:Microsoft.Xna.Framework.Audio.SoundEffect) class. This class provides two ways to play sounds: + +1. Direct playback using [**SoundEffect.Play**](xref:Microsoft.Xna.Framework.Audio.SoundEffect.Play): + + [!code-csharp[](./snippets/play_soundeffect.cs)] + +2. Creating an instance using [**SoundEffect.CreateInstance**](xref:Microsoft.Xna.Framework.Audio.SoundEffect.CreateInstance): + + [!code-csharp[](./snippets/play_soundeffect_instance.cs)] + +- Use [**SoundEffect.Play**](xref:Microsoft.Xna.Framework.Audio.SoundEffect.Play) for simple sound effects that you just want to play once. +- Use [**SoundEffect.CreateInstance**](xref:Microsoft.Xna.Framework.Audio.SoundEffect.CreateInstance) when you need more control over the sound effect, like adjusting volume, looping, or managing multiple instances of the same sound. + +[**SoundEffectInstance**](xref:Microsoft.Xna.Framework.Audio.SoundEffectInstance) contains several properties that can be used to control how the sound effect is played: + +| Property | Type | Description | +| ------------------------------------------------------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------- | +| [**IsLooped**](xref:Microsoft.Xna.Framework.Audio.SoundEffectInstance.IsLooped) | `bool` | Whether the sound should loop when it reaches the end. | +| [**Pan**](xref:Microsoft.Xna.Framework.Audio.SoundEffectInstance.Pan) | `float` | Stereo panning between -1.0f (full left) and 1.0f (full right). | +| [**Pitch**](xref:Microsoft.Xna.Framework.Audio.SoundEffectInstance.Pitch) | `float` | Pitch adjustment between -1.0f (down one octave) and 1.0f (up one octave). | +| [**State**](xref:Microsoft.Xna.Framework.Audio.SoundEffectInstance.State) | [**SoundState**](xref:Microsoft.Xna.Framework.Audio.SoundState) | Current playback state (Playing, Paused, or Stopped). | +| [**Volume**](xref:Microsoft.Xna.Framework.Audio.SoundEffectInstance.Volume) | `float` | Volume level between 0.0f (silent) and 1.0f (full volume). | + +> [!NOTE] +> There is a lot more behind SoundEffectInstances such as the ability to play 3D sounds, as well as the advanced capabilities of the [DynamicSoundEffectInstance](xref:Microsoft.Xna.Framework.Audio.SoundEffectInstance) which can build audio streams. However, these are beyond the scope of this beginners guide. If you wish to know more, then check the [MonoGame documentation](/articles/getting_to_know/whatis/audio/) + +## Playing Music + +Unlike sound effects, music is played through the [**MediaPlayer**](xref:Microsoft.Xna.Framework.Media.MediaPlayer) class. This static class manages playback of [**Song**](xref:Microsoft.Xna.Framework.Media.Song) instances and provides global control over music playback: + +[!code-csharp[](./snippets/play_song.cs)] + +> [!IMPORTANT] +> While [**SoundEffect**](xref:Microsoft.Xna.Framework.Audio.SoundEffect) instances can be played simultaneously, trying to play a new [**Song**](xref:Microsoft.Xna.Framework.Media.Song) while another is playing will stop the current song in the best case, and in the worst case cause a crash on some platforms. In the example above, the state of the media player is checked first before we tell it to play a song. Checking the state first and stopping it manually if it is playing is best practice to prevent potential crashes. + +## Adding Audio To Our Game + +Before we can add audio to our game, we need some sound files to work with. Download the following audio files: + +- [bounce.wav](./files/bounce.wav){download} - For when the bat bounces off screen edges +- [collect.wav](./files/collect.wav){download} - For when the slime eats the bat +- [theme.ogg](./files/theme.ogg){download} - Background music + +> [!NOTE] +> +> - *bounce.wav* is "Retro Impact Punch 07" by Davit Masia (). +> - *collect.wav* is "Retro Jump Classic 08" by Davit Masia (). +> - *theme.mp3* is "Exploration" by Luis Zuno ([@ansimuz](https://twitter.com/ansimuz)). + +Add these files to your content project using the MGCB Editor: + +1. Open the *Content.mgcb* file in the MGCB Editor. +2. Create a new folder called `audio` (right-click *Content* > *Add* > *New Folder*). +3. Right-click the new *audio* folder and choose *Add* > *Existing Item...*. +4. Navigate to and select the audio files you downloaded. +5. For each file that is added, check its properties in the Properties panel: + - For `.wav` files, ensure the *Processor* is set to `Sound Effect`. + - For `.mp3` files, ensure the *Processor* is set to `Song`. +6. Save the changes and close the MGCB Editor. + +Next, open the `Game1.cs` file and update it to the following: + +[!code-csharp[](./snippets/game1.cs?highlight=3,6,39-43,92-111,203-204,222-223)] + +The key changes here are: + +1. Added the `using Microsoft.Xna.Framework.Audio;` and `using Microsoft.Xna.Framework.Media;` directories to access the [**Song**](xref:Microsoft.Xna.Framework.Media.Song) and [**SoundEffect**](xref:Microsoft.Xna.Framework.Audio) classes. +2. Added the `_boundSoundEffect` and `_collectSoundEffect` fields to store those sound effects when loaded and use them for playback. +3. In [**LoadContent**](xref:Microsoft.Xna.Framework.Game.LoadContent) + 1. The bounce and collect sound effects are loaded using the content manager. + 2. The background theme music is loaded using the content manager. + 3. The background music is played using the media player, checking its state first. + 4. The [**MediaPlayer.IsRepeating**](xref:Microsoft.Xna.Framework.Media.MediaPlayer.IsRepeating) is set to `true` so the background music loops. +4. In [**Update**](xref:Microsoft.Xna.Framework.Game.Update(Microsoft.Xna.Framework.GameTime)): + 1. The bounce sound effect is played when the bat bounces off the edge of the screen. + 2. The collect sound effect is played when the slime eats the bat. + +Running the game now, the theme music plays in the background, you can hear the bat bounce off the edge of the screen, and if you move the slime to eat the bat, you hear that as well. + +| ![Figure 14-4: Gameplay with audio.](./videos/gameplay.webm) | +| :----------------------------------------------------------: | +| **Figure 14-4: Gameplay with audio.** | + +## Conclusion + +In this chapter, you accomplished the following: + +- Learned about MonoGame's audio system including sound effects and music. +- Explored the key differences between: + - Sound effects (short, multiple simultaneous playback). + - Music (longer, streamed, single playback). +- Added audio content to your game project through the content pipeline. +- Loaded audio files using the ContentManager. +- Implemented audio feedback in your game: + - Background music to set atmosphere. + - Sound effects for bat bouncing and collection events. +- Learned best practices for handling audio playback across different platforms. + +In the next chapter, we will explore additional ways to manage audio by creating an audio controller module that will help with common tasks such as volume control, muting, and state management. + +## Test Your Knowledge + +1. What are the two main classes MonoGame provides for audio playback and how do they differ? + + :::question-answer + MonoGame provides: + + - [**SoundEffect**](xref:Microsoft.Xna.Framework.Audio.SoundEffect) for short audio clips (loaded entirely into memory, multiple can play at once) and + - [**Song**](xref:Microsoft.Xna.Framework.Media.Song) for longer audio like music (streamed from storage, only one can play at a time). + + ::: + +2. Why is it important to check if [**MediaPlayer**](xref:Microsoft.Xna.Framework.Media.MediaPlayer) is already playing before starting a new song? + + :::question-answer + Checking if MediaPlayer is already playing and stopping it if necessary helps prevent crashes on some platforms. Since only one song can play at a time, properly stopping the current song before starting a new one ensures reliable behavior across different platforms. + ::: + +3. What file formats are best suited for sound effects and music, respectively, and why? + + :::question-answer + For sound effects, .wav files are generally best because they are uncompressed and load quickly into memory for immediate playback. For music, compressed formats like .mp3 or .ogg are better suited because they greatly reduce file size while maintaining good audio quality, which is important for longer audio that's streamed rather than fully loaded. + ::: + +4. What is the difference between using [**SoundEffect.Play**](xref:Microsoft.Xna.Framework.Audio.SoundEffect.Play) directly and creating a [**SoundEffectInstance**](xref:Microsoft.Xna.Framework.Audio.SoundEffectInstance)? + + :::question-answer + + - [**SoundEffect.Play**](xref:Microsoft.Xna.Framework.Audio.SoundEffect.Play) is simpler but provides limited control - it plays the sound once with basic volume/pitch/pan settings. + - Creating a [**SoundEffectInstance**](xref:Microsoft.Xna.Framework.Audio.SoundEffectInstance) gives more control including the ability to pause, resume, loop, and change properties during playback, as well as track the sound's state. + + ::: + +5. How many sound effects can play simultaneously on different platforms? + + :::question-answer + The number of simultaneous sound effects varies by platform: + + - Mobile platforms: maximum of 32 sounds. + - Desktop platforms: maximum of 256 sounds. + - Consoles and other platforms have their own constraints specified in their respective SDK documentation. + ::: diff --git a/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/game1.cs b/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/game1.cs new file mode 100644 index 00000000..e06f1633 --- /dev/null +++ b/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/game1.cs @@ -0,0 +1,352 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using Microsoft.Xna.Framework.Media; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; +using MonoGameLibrary.Input; + +namespace DungeonSlime; + +public class Game1 : Core +{ + // Defines the slime animated sprite. + private AnimatedSprite _slime; + + // Defines the bat animated sprite. + private AnimatedSprite _bat; + + // Tracks the position of the slime. + private Vector2 _slimePosition; + + // Speed multiplier when moving. + private const float MOVEMENT_SPEED = 5.0f; + + // Tracks the position of the bat. + private Vector2 _batPosition; + + // Tracks the velocity of the bat. + private Vector2 _batVelocity; + + // Defines the tilemap to draw. + private Tilemap _tilemap; + + // Defines the bounds of the room that the slime and bat are contained within. + private Rectangle _roomBounds; + + // The sound effect to play when the bat bounces off the edge of the screen. + private SoundEffect _bounceSoundEffect; + + // The sound effect to play when the slime eats a bat. + private SoundEffect _collectSoundEffect; + + public Game1() : base("Dungeon Slime", 1280, 720, false) + { + + } + + protected override void Initialize() + { + base.Initialize(); + + Rectangle screenBounds = GraphicsDevice.PresentationParameters.Bounds; + + _roomBounds = new Rectangle( + (int)_tilemap.TileWidth, + (int)_tilemap.TileHeight, + screenBounds.Width - (int)_tilemap.TileWidth * 2, + screenBounds.Height - (int)_tilemap.TileHeight * 2 + ); + + // Initial slime position will be the center tile of the tile map. + int centerRow = _tilemap.Rows / 2; + int centerColumn = _tilemap.Columns / 2; + _slimePosition = new Vector2(centerColumn * _tilemap.TileWidth, centerRow * _tilemap.TileHeight); + + // Initial bat position will the in the top left corner of the room + _batPosition = new Vector2(_roomBounds.Left, _roomBounds.Top); + + // Assign the initial random velocity to the bat. + AssignRandomBatVelocity(); + } + + protected override void LoadContent() + { + // Create the texture atlas from the XML configuration file + TextureAtlas atlas = TextureAtlas.FromFile(Content, "images/atlas-definition.xml"); + + // Create the slime animated sprite from the atlas. + _slime = atlas.CreateAnimatedSprite("slime-animation"); + _slime.Scale = new Vector2(4.0f, 4.0f); + + // Create the bat animated sprite from the atlas. + _bat = atlas.CreateAnimatedSprite("bat-animation"); + _bat.Scale = new Vector2(4.0f, 4.0f); + + // Create the tilemap from the XML configuration file. + _tilemap = Tilemap.FromFile(Content, "images/tilemap-definition.xml"); + _tilemap.Scale = new Vector2(4.0f, 4.0f); + + // Load the bounce sound effect + _bounceSoundEffect = Content.Load("audio/bounce"); + + // Load the collect sound effect + _collectSoundEffect = Content.Load("audio/collect"); + + // Load the background theme music + Song theme = Content.Load("audio/theme"); + + // Ensure media player is not already playing on device, if so, stop it + if (MediaPlayer.State == MediaState.Playing) + { + MediaPlayer.Stop(); + } + + // Play the background theme music. + MediaPlayer.Play(theme); + + // Set the theme music to repeat. + MediaPlayer.IsRepeating = true; + } + + protected override void Update(GameTime gameTime) + { + if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape)) + Exit(); + + // Update the slime animated sprite. + _slime.Update(gameTime); + + // Update the bat animated sprite. + _bat.Update(gameTime); + + // Check for keyboard input and handle it. + CheckKeyboardInput(); + + // Check for gamepad input and handle it. + CheckGamePadInput(); + + // Creating a bounding circle for the slime + Circle slimeBounds = new Circle( + (int)(_slimePosition.X + (_slime.Width * 0.5f)), + (int)(_slimePosition.Y + (_slime.Height * 0.5f)), + (int)(_slime.Width * 0.5f) + ); + + // Use distance based checks to determine if the slime is within the + // bounds of the game screen, and if it is outside that screen edge, + // move it back inside. + if (slimeBounds.Left < _roomBounds.Left) + { + _slimePosition.X = _roomBounds.Left; + } + else if (slimeBounds.Right > _roomBounds.Right) + { + _slimePosition.X = _roomBounds.Right - _slime.Width; + } + + if (slimeBounds.Top < _roomBounds.Top) + { + _slimePosition.Y = _roomBounds.Top; + } + else if (slimeBounds.Bottom > _roomBounds.Bottom) + { + _slimePosition.Y = _roomBounds.Bottom - _slime.Height; + } + + // Calculate the new position of the bat based on the velocity + Vector2 newBatPosition = _batPosition + _batVelocity; + + // Create a bounding circle for the bat + Circle batBounds = new Circle( + (int)(newBatPosition.X + (_bat.Width * 0.5f)), + (int)(newBatPosition.Y + (_bat.Height * 0.5f)), + (int)(_bat.Width * 0.5f) + ); + + Vector2 normal = Vector2.Zero; + + // Use distance based checks to determine if the bat is within the + // bounds of the game screen, and if it is outside that screen edge, + // reflect it about the screen edge normal + if (batBounds.Left < _roomBounds.Left) + { + normal.X = Vector2.UnitX.X; + newBatPosition.X = _roomBounds.Left; + } + else if (batBounds.Right > _roomBounds.Right) + { + normal.X = -Vector2.UnitX.X; + newBatPosition.X = _roomBounds.Right - _bat.Width; + } + + if (batBounds.Top < _roomBounds.Top) + { + normal.Y = Vector2.UnitY.Y; + newBatPosition.Y = _roomBounds.Top; + } + else if (batBounds.Bottom > _roomBounds.Bottom) + { + normal.Y = -Vector2.UnitY.Y; + newBatPosition.Y = _roomBounds.Bottom - _bat.Height; + } + + // If the normal is anything but Vector2.Zero, this means the bat had + // moved outside the screen edge so we should reflect it about the + // normal. + if (normal != Vector2.Zero) + { + _batVelocity = Vector2.Reflect(_batVelocity, normal); + + // Play the bounce sound effect + _bounceSoundEffect.Play(); + } + + _batPosition = newBatPosition; + + if (slimeBounds.Intersects(batBounds)) + { + // Choose a random row and column based on the total number of each + int column = Random.Shared.Next(1, _tilemap.Columns - 1); + int row = Random.Shared.Next(1, _tilemap.Rows - 1); + + // Change the bat position by setting the x and y values equal to + // the column and row multiplied by the width and height. + _batPosition = new Vector2(column * _bat.Width, row * _bat.Height); + + // Assign a new random velocity to the bat + AssignRandomBatVelocity(); + + // Play the collect sound effect + _collectSoundEffect.Play(); + } + + base.Update(gameTime); + } + + private void AssignRandomBatVelocity() + { + // Generate a random angle + float angle = (float)(Random.Shared.NextDouble() * Math.PI * 2); + + // Convert angle to a direction vector + float x = (float)Math.Cos(angle); + float y = (float)Math.Sin(angle); + Vector2 direction = new Vector2(x, y); + + // Multiply the direction vector by the movement speed + _batVelocity = direction * MOVEMENT_SPEED; + } + + private void CheckKeyboardInput() + { + // If the space key is held down, the movement speed increases by 1.5 + float speed = MOVEMENT_SPEED; + if (Input.Keyboard.IsKeyDown(Keys.Space)) + { + speed *= 1.5f; + } + + // If the W or Up keys are down, move the slime up on the screen. + if (Input.Keyboard.IsKeyDown(Keys.W) || Input.Keyboard.IsKeyDown(Keys.Up)) + { + _slimePosition.Y -= speed; + } + + // if the S or Down keys are down, move the slime down on the screen. + if (Input.Keyboard.IsKeyDown(Keys.S) || Input.Keyboard.IsKeyDown(Keys.Down)) + { + _slimePosition.Y += speed; + } + + // If the A or Left keys are down, move the slime left on the screen. + if (Input.Keyboard.IsKeyDown(Keys.A) || Input.Keyboard.IsKeyDown(Keys.Left)) + { + _slimePosition.X -= speed; + } + + // If the D or Right keys are down, move the slime right on the screen. + if (Input.Keyboard.IsKeyDown(Keys.D) || Input.Keyboard.IsKeyDown(Keys.Right)) + { + _slimePosition.X += speed; + } + } + + private void CheckGamePadInput() + { + GamePadInfo gamePadOne = Input.GamePads[(int)PlayerIndex.One]; + + // If the A button is held down, the movement speed increases by 1.5 + // and the gamepad vibrates as feedback to the player. + float speed = MOVEMENT_SPEED; + if (gamePadOne.IsButtonDown(Buttons.A)) + { + speed *= 1.5f; + GamePad.SetVibration(PlayerIndex.One, 1.0f, 1.0f); + } + else + { + GamePad.SetVibration(PlayerIndex.One, 0.0f, 0.0f); + } + + // Check thumbstick first since it has priority over which gamepad input + // is movement. It has priority since the thumbstick values provide a + // more granular analog value that can be used for movement. + if (gamePadOne.LeftThumbStick != Vector2.Zero) + { + _slimePosition.X += gamePadOne.LeftThumbStick.X * speed; + _slimePosition.Y -= gamePadOne.LeftThumbStick.Y * speed; + } + else + { + // If DPadUp is down, move the slime up on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadUp)) + { + _slimePosition.Y -= speed; + } + + // If DPadDown is down, move the slime down on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadDown)) + { + _slimePosition.Y += speed; + } + + // If DPapLeft is down, move the slime left on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadLeft)) + { + _slimePosition.X -= speed; + } + + // If DPadRight is down, move the slime right on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadRight)) + { + _slimePosition.X += speed; + } + } + } + + protected override void Draw(GameTime gameTime) + { + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + // Draw the tilemap + _tilemap.Draw(SpriteBatch); + + // Draw the slime sprite. + _slime.Draw(SpriteBatch, _slimePosition); + + // Draw the bat sprite. + _bat.Draw(SpriteBatch, _batPosition); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/load_song.cs b/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/load_song.cs new file mode 100644 index 00000000..c58272ab --- /dev/null +++ b/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/load_song.cs @@ -0,0 +1,2 @@ +// Loading a Song using the content pipeline +Song song = Content.Load("song"); \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/load_soundeffect.cs b/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/load_soundeffect.cs new file mode 100644 index 00000000..4ca6fd7f --- /dev/null +++ b/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/load_soundeffect.cs @@ -0,0 +1,2 @@ +// Loading a SoundEffect using the content pipeline +SoundEffect soundEffect = Content.Load("soundEffect"); \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/play_song.cs b/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/play_song.cs new file mode 100644 index 00000000..be6c0782 --- /dev/null +++ b/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/play_song.cs @@ -0,0 +1,17 @@ +// Loading a Song using the content pipeline +Song song = Content.Load("song"); + +// Set whether the song should repeat when finished +MediaPlayer.IsRepeating = true; + +// Adjust the volume (0.0f to 1.0f) +MediaPlayer.Volume = 0.5f; + +// Check if the media player is already playing, if so, stop it +if(MediaPlayer.State == MediaState.Playing) +{ + MediaPlayer.Stop(); +} + +// Start playing the background music +MediaPlayer.Play(song); \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/play_soundeffect.cs b/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/play_soundeffect.cs new file mode 100644 index 00000000..cff82a26 --- /dev/null +++ b/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/play_soundeffect.cs @@ -0,0 +1,5 @@ +// Loading a SoundEffect using the content pipeline +SoundEffect soundEffect = Content.Load("soundEffect"); + +// Play the sound effect with default settings +soundEffect.Play(); \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/play_soundeffect_instance.cs b/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/play_soundeffect_instance.cs new file mode 100644 index 00000000..9be58a45 --- /dev/null +++ b/articles/tutorials/building_2d_games/14_soundeffects_and_music/snippets/play_soundeffect_instance.cs @@ -0,0 +1,12 @@ +// Loading a SoundEffect using the content pipeline +SoundEffect soundEffect = Content.Load("soundEffect"); + +// Create an instance we can control +SoundEffectInstance soundEffectInstance = soundEffect.CreateInstance(); + +// Adjust the properties of the instance as needed +soundEffectInstance.IsLooped = true; // Make it loop +soundEffectInstance.Volume = 0.5f; // Set half volume. + +// Play the sound effect using the instance. +soundEffectInstance.Play(); \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/14_soundeffects_and_music/videos/gameplay.webm b/articles/tutorials/building_2d_games/14_soundeffects_and_music/videos/gameplay.webm new file mode 100644 index 00000000..1c1981cb Binary files /dev/null and b/articles/tutorials/building_2d_games/14_soundeffects_and_music/videos/gameplay.webm differ diff --git a/articles/tutorials/building_2d_games/15_audio_controller/index.md b/articles/tutorials/building_2d_games/15_audio_controller/index.md new file mode 100644 index 00000000..80f7bc69 --- /dev/null +++ b/articles/tutorials/building_2d_games/15_audio_controller/index.md @@ -0,0 +1,175 @@ +--- +title: "Chapter 15: Audio Controller" +description: "Learn how to create a reusable audio controller class to manage sound effects and music, including volume control, muting/unmuting, and proper resource cleanup." +--- + +While playing sounds and music using the simplified sound API is straightforward, a game needs to handle various audio states and resource cleanup including: + +- Track and manage sound effect instances that are created. +- Dispose of sound effect instances when they are finished. +- Handle volume control for songs and sound effects. +- Manage audio states (pause/resume, mute/unmute). + +In this chapter you will: + +- Learn how to create a central audio management system. +- Implement proper resource tracking and cleanup for sound effects. +- Build methods to control audio state (play/pause, mute/unmute). +- Add global volume control for different audio types. +- Integrate the audio controller with your game's core systems. +- Implement keyboard shortcuts for audio control. + +By the end of this chapter, you will have an audio control system that can be easily reused in future game projects. + +## The AudioController Class + +To get started, in the *MonoGameLibrary* project: + +1. Create a new folder named `Audio`. +2. Add a new class file named `AudioController.cs` to the `Audio` folder you just created. +3. Add the following code as the initial structure for the class + + [!code-csharp[](./snippets/audiocontroller.cs#declaration)] + + > [!NOTE] + > The `AudioController` class will implement the `IDisposable` interface, This interface is part of .NET and provides a standardized implementation for an object to release resources. Implementing `IDisposable` allows other code to properly clean up the resources held by our audio controller when it is no longer needed. For more information on `IDisposable`, you can read the [Implement a Dispose Method](https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose) article on Microsoft Learn. + +### AudioController Properties and Fields + +The `AudioController` will need to track sound effect instances created for cleanup and track the state and volume levels of songs and sound effects when toggling between mute states. + +Add the following fields and properties: + +[!code-csharp[](./snippets/audiocontroller.cs#properties)] + +### AudioController Constructor + +The constructor just initializes the collection used to track the sound effect instances. + +Add the following constructor and finalizer: + +[!code-csharp[](./snippets/audiocontroller.cs#ctors)] + +> [!NOTE] +> The `AudioController` class implements a [finalizer](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/finalizers) method `~AudioManager()`. This method is called when an instance of the class is collected by the garbage collector and is here as part of the `IDisposable` implementation. + +### AudioController Methods + +The `AudioController` needs methods to: + +- Update it to check for resources to clean up. +- Playing sound effects and songs +- State management (play/pause, mute/unmute) +- Volume control +- Implement the `IDisposable` interface. + +So lets add them below. + +#### AudioController Update + +The `Update` method will check for existing sound effect instances that have expired and properly dispose of them. Add the following method: + +[!code-csharp[](./snippets/audiocontroller.cs#update)] + +#### AudioController Playback + +While the MonoGame simplified audio API allows sound effects to be played in a fire and forget manner, doing it this way does not work if you need to pause them because the game paused. Instead, we can add playback methods through the `AudioController` that can track the sound effect instances and pause them if needed, as well as checking the media player state before playing a song. + +Add the following methods: + +[!code-csharp[](./snippets/audiocontroller.cs#playback)] + +#### AudioController State Control + +The `AudioController` provides methods to control the state of audio playback including pausing and resuming audio as well as muting and unmuting. + +Add the following methods: + +[!code-csharp[](./snippets/audiocontroller.cs#state)] + +#### AudioController IDisposable Implementation + +Finally, the `AudioController` is required to implement the `IDisposable` interface, to complete this add the following methods: + +[!code-csharp[](./snippets/audiocontroller.cs#idisposable)] + +Games often use limited system resources like audio channels, when we are done with these resources we need to clean them up properly. In .NET, the standard way to handle resource cleanup is through the `IDisposable` interface. + +Think of `IDisposable` like a cleanup checklist that runs when you are finished with something: + +1. The interface provides a `Dispose` method that contains all cleanup logic. +2. When called, `Dispose` releases any resources the class was using. +3. Even if you forget to call `Dispose`, the finalizer acts as a backup cleanup mechanism. + +For our `AudioController`, implementing `IDisposable` means we can ensure all sound effect instances are properly stopped and disposed when our game ends, preventing resource leaks. + +> [!NOTE] +> Fore more information on `IDisposable` and the `Dispose` method, check out the [Implementing a Dispose Method](https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose) article on Microsoft Learn. + +## Implementing the AudioController Class + +Now that we have the audio controller class complete, we can update the game to use it. We will do this in two steps: + +1. First, update the `Core` class to add the `AudioController` globally. +1. Update the `Game1` class to use the global audio controller from `Core`. + +### Updating the Core Class + +The `Core` class serves as our the base game class, so we will update it first to add and expose the `AudioController` globally. Open the `Core.cs` file in the *MonoGameLibrary* project and update it to the following: + +[!code-csharp[](./snippets/core.cs?highlight=6,50-53,112-113,116-122,129-130)] + +The key changes made here are: + +1. Added the `using MonoGameLibrary.Audio;` directive to access the `AudioController` class. +2. Added a static `Audio` property to provide global access to the audio controller. +3. Created the new audio controller instance in the `Initialize` method. +4. Added an override for the `UnloadContent` method where we dispose of the audio controller. +5. The audio controller is updated in the `Update` method. + +### Updating the Game1 Class + +Next, update the `Game1` class to use the audio controller for audio playback. Open `Game1.cs` and make the following updates: + +[!code-csharp[](./snippets/game1.cs?highlight=45-46,77-78,104-105,197-198,216-217,270-288)] + +> [!NOTE] +> Note there were a lot of replacements in the `LoadContent` method, switching from loading and initializing the background Song and replacing it with a call to the new `AudioController` to do all the work managing the Song reference. Much cleaner. + +The key changes made here are: + +1. The `_themeSong` field is added to store a reference to the background song to play. +2. In [**LoadContent**](xref:Microsoft.Xna.Framework.Game.LoadContent), the background theme song is loaded using hte content manager. +3. In [**Initialize**](xref:Microsoft.Xna.Framework.Game.Initialize), the audio manager is used to play the background theme song. +4. In [**Update**](xref:Microsoft.Xna.Framework.Game.Update(Microsoft.Xna.Framework.GameTime)) the audio manager is used to play the bounce and collect sound effects. +5. In `CheckKeyboardInput` the following checks were added + 1. If the M key on the keyboard is pressed, it will toggle mute for all audio. + 2. If the + key is pressed, the song and sound effect volumes are increased by `0.1f`. + 3. If the - key is pressed, the song and sound effect volumes are decreased by `0.1f`. + +Running the game now will produce the same result as the previous chapter, only now the lifetime of sound effects and the state management of audio is done through the new audio controller. You can also mute and unumte the audio with the M key and increase and decrease the volume using the + and - keys. + +| ![Figure 15-1: Gameplay with audio.](./videos/gameplay.webm) | +|:--------------------------------------------------------------------------------------:| +| **Figure 15-1: Gameplay with audio.** | + +> [!NOTE] +> You may note that while we added keybindings to change the audio settings, we did not add any bindings for the GamePad. This is simply becuase this is not normally how you would adjust these values on a console, on consoles you would have a settings/options screen to update them. +> +> Later in [Chapter 20: Implementing UI with GUM](../20_implementing_ui_with_gum/index.md) we will add an Options screen to adjust all the audio values for the game. + +## Conclusion + +In this chapter, you accomplished the following: + +- Created a reusable `AudioController` class to centralize audio management. +- Learned about proper resource management for audio using the `IDisposable` pattern. +- Implemented tracking and cleanup of sound effect instances. +- Added global volume control for both sound effects and music. +- Created methods to toggle audio states (play/pause, mute/unmute). +- Updated the `Core` class to provide global access to the audio controller. +- Added keyboard controls to adjust volume and toggle mute state. + +The `AudioController` class you created is a significant improvement over directly using MonoGame's audio APIs. It handles common audio management tasks that would otherwise need to be implemented repeatedly in different parts of your game. By centralizing these functions, you make your code more maintainable and provide a consistent audio experience across your game. + +In the next chapter, we will start exploring fonts and adding text to the game. diff --git a/articles/tutorials/building_2d_games/15_audio_controller/snippets/audiocontroller.cs b/articles/tutorials/building_2d_games/15_audio_controller/snippets/audiocontroller.cs new file mode 100644 index 00000000..34b57261 --- /dev/null +++ b/articles/tutorials/building_2d_games/15_audio_controller/snippets/audiocontroller.cs @@ -0,0 +1,297 @@ +#region declaration +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Media; + +namespace MonoGameLibrary.Audio; + +public class AudioController : IDisposable +{ + +} +#endregion +{ + #region properties + // Tracks sound effect instances created so they can be paused, unpaused, and/or disposed. + private readonly List _activeSoundEffectInstances; + + // Tracks the volume for song playback when muting and unmuting. + private float _previousSongVolume; + + // Tracks the volume for sound effect playback when muting and unmuting. + private float _previousSoundEffectVolume; + + /// + /// Gets a value that indicates if audio is muted. + /// + public bool IsMuted { get; private set; } + +/// + /// Gets or Sets the global volume of songs. + /// + /// + /// If IsMuted is true, the getter will always return back 0.0f and the + /// setter will ignore setting the volume. + /// + public float SongVolume + { + get + { + if(IsMuted) + { + return 0.0f; + } + + return MediaPlayer.Volume; + } + set + { + if(IsMuted) + { + return; + } + + MediaPlayer.Volume = Math.Clamp(value, 0.0f, 1.0f); + } + } + + /// + /// Gets or Sets the global volume of sound effects. + /// + /// + /// If IsMuted is true, the getter will always return back 0.0f and the + /// setter will ignore setting the volume. + /// + public float SoundEffectVolume + { + get + { + if(IsMuted) + { + return 0.0f; + } + + return SoundEffect.MasterVolume; + } + set + { + if(IsMuted) + { + return; + } + + SoundEffect.MasterVolume = Math.Clamp(value, 0.0f, 1.0f); + } + } + + /// + /// Gets a value that indicates if this audio controller has been disposed. + /// + public bool IsDisposed {get; private set; } + #endregion + + #region ctors + /// + /// Creates a new audio controller instance. + /// + public AudioController() + { + _activeSoundEffectInstances = new List(); + } + + // Finalizer called when object is collected by the garbage collector + ~AudioController() => Dispose(false); + #endregion + + #region update + /// + /// Updates this audio controller + /// + public void Update() + { + int index = 0; + + while (index < _activeSoundEffectInstances.Count) + { + SoundEffectInstance instance = _activeSoundEffectInstances[index]; + + if (instance.State == SoundState.Stopped && !instance.IsDisposed) + { + instance.Dispose(); + } + + _activeSoundEffectInstances.RemoveAt(index); + } + } + #endregion + + #region idisposable + /// + /// Disposes of this audio controller and cleans up resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes this audio controller and cleans up resources. + /// + /// Indicates whether managed resources should be disposed. + protected void Dispose(bool disposing) + { + if(IsDisposed) + { + return; + } + + if (disposing) + { + foreach (SoundEffectInstance soundEffectInstance in _activeSoundEffectInstances) + { + soundEffectInstance.Dispose(); + } + _activeSoundEffectInstances.Clear(); + } + + IsDisposed = true; + } + #endregion + + #region playback + /// + /// Plays the given sound effect. + /// + /// The sound effect to play. + /// The sound effect instance created by this method. + public SoundEffectInstance PlaySoundEffect(SoundEffect soundEffect) + { + return PlaySoundEffect(soundEffect, 1.0f, 1.0f, 0.0f, false); + } + + /// + /// Plays the given sound effect with the specified properties. + /// + /// The sound effect to play. + /// The volume, ranging from 0.0 (silence) to 1.0 (full volume). + /// The pitch adjustment, ranging from -1.0 (down an octave) to 0.0 (no change) to 1.0 (up an octave). + /// The panning, ranging from -1.0 (left speaker) to 0.0 (centered), 1.0 (right speaker). + /// Whether the the sound effect should loop after playback. + /// The sound effect instance created by playing the sound effect. + /// The sound effect instance created by this method. + public SoundEffectInstance PlaySoundEffect(SoundEffect soundEffect, float volume, float pitch, float pan, bool isLooped) + { + // Create an instance from the sound effect given. + SoundEffectInstance soundEffectInstance = soundEffect.CreateInstance(); + + // Apply the volume, pitch, pan, and loop values specified. + soundEffectInstance.Volume = volume; + soundEffectInstance.Pitch = pitch; + soundEffectInstance.Pan = pan; + soundEffectInstance.IsLooped = isLooped; + + // Tell the instance to play + soundEffectInstance.Play(); + + // Add it to the active instances for tracking + _activeSoundEffectInstances.Add(soundEffectInstance); + + return soundEffectInstance; + } + + /// + /// Plays the given song. + /// + /// The song to play. + /// Optionally specify if the song should repeat. Default is true. + public void PlaySong(Song song, bool isRepeating = true) + { + // Check if the media player is already playing, if so, stop it. + // If we do not stop it, this could cause issues on some platforms + if (MediaPlayer.State == MediaState.Playing) + { + MediaPlayer.Stop(); + } + + MediaPlayer.Play(song); + MediaPlayer.IsRepeating = isRepeating; + } + #endregion + + #region state + /// + /// Pauses all audio. + /// + public void PauseAudio() + { + // Pause any active songs playing + MediaPlayer.Pause(); + + // Pause any active sound effects + foreach (SoundEffectInstance soundEffectInstance in _activeSoundEffectInstances) + { + soundEffectInstance.Pause(); + } + } + + /// + /// Resumes play of all previous paused audio. + /// + public void ResumeAudio() + { + // Resume paused music + MediaPlayer.Resume(); + + // Resume any active sound effects + foreach (SoundEffectInstance soundEffectInstance in _activeSoundEffectInstances) + { + soundEffectInstance.Resume(); + } + } + + /// + /// Mutes all audio. + /// + public void MuteAudio() + { + // Store the volume so they can be restored during ResumeAudio + _previousSongVolume = MediaPlayer.Volume; + _previousSoundEffectVolume = SoundEffect.MasterVolume; + + // Set all volumes to 0 + MediaPlayer.Volume = 0.0f; + SoundEffect.MasterVolume = 0.0f; + + IsMuted = true; + } + + /// + /// Unmutes all audio to the volume level prior to muting. + /// + public void UnmuteAudio() + { + // Restore the previous volume values + MediaPlayer.Volume = _previousSongVolume; + SoundEffect.MasterVolume = _previousSoundEffectVolume; + + IsMuted = false; + } + + /// + /// Toggles the current audio mute state. + /// + public void ToggleMute() + { + if (IsMuted) + { + UnmuteAudio(); + } + else + { + MuteAudio(); + } + } + #endregion +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/15_audio_controller/snippets/core.cs b/articles/tutorials/building_2d_games/15_audio_controller/snippets/core.cs new file mode 100644 index 00000000..592e1bd9 --- /dev/null +++ b/articles/tutorials/building_2d_games/15_audio_controller/snippets/core.cs @@ -0,0 +1,139 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary.Audio; +using MonoGameLibrary.Input; + +namespace MonoGameLibrary; + +public class Core : Game +{ + internal static Core s_instance; + + /// + /// Gets a reference to the Core instance. + /// + public static Core Instance => s_instance; + + /// + /// Gets the graphics device manager to control the presentation of graphics. + /// + public static GraphicsDeviceManager Graphics { get; private set; } + + /// + /// Gets the graphics device used to create graphical resources and perform primitive rendering. + /// + public static new GraphicsDevice GraphicsDevice { get; private set; } + + /// + /// Gets the sprite batch used for all 2D rendering. + /// + public static SpriteBatch SpriteBatch { get; private set; } + + /// + /// Gets the content manager used to load global assets. + /// + public static new ContentManager Content { get; private set; } + + /// + /// Gets a reference to to the input management system. + /// + public static InputManager Input { get; private set; } + + /// + /// Gets or Sets a value that indicates if the game should exit when the esc key on the keyboard is pressed. + /// + public static bool ExitOnEscape { get; set; } + + /// + /// Gets a reference to the audio control system. + /// + public static AudioController Audio { get; private set; } + + /// + /// Creates a new Core instance. + /// + /// The title to display in the title bar of the game window. + /// The initial width, in pixels, of the game window. + /// The initial height, in pixels, of the game window. + /// Indicates if the game should start in fullscreen mode. + public Core(string title, int width, int height, bool fullScreen) + { + // Ensure that multiple cores are not created. + if (s_instance != null) + { + throw new InvalidOperationException($"Only a single Core instance can be created"); + } + + // Store reference to engine for global member access. + s_instance = this; + + // Create a new graphics device manager. + Graphics = new GraphicsDeviceManager(this); + + // Set the graphics defaults + Graphics.PreferredBackBufferWidth = width; + Graphics.PreferredBackBufferHeight = height; + Graphics.IsFullScreen = fullScreen; + + // Apply the graphic presentation changes + Graphics.ApplyChanges(); + + // Set the window title + Window.Title = title; + + // Set the core's content manager to a reference of hte base Game's + // content manager. + Content = base.Content; + + // Set the root directory for content + Content.RootDirectory = "Content"; + + // Mouse is visible by default + IsMouseVisible = true; + } + + protected override void Initialize() + { + base.Initialize(); + + // Set the core's graphics device to a reference of the base Game's + // graphics device. + GraphicsDevice = base.GraphicsDevice; + + // Create the sprite batch instance. + SpriteBatch = new SpriteBatch(GraphicsDevice); + + // Create a new input manager + Input = new InputManager(); + + // Create a new audio controller. + Audio = new AudioController(); + } + + protected override void UnloadContent() + { + // Dispose of the audio controller. + Audio.Dispose(); + + base.UnloadContent(); + } + + protected override void Update(GameTime gameTime) + { + // Update the input manager. + Input.Update(gameTime); + + // Update the audio controller. + Audio.Update(); + + if (ExitOnEscape && Input.Keyboard.IsKeyDown(Keys.Escape)) + { + Exit(); + } + + base.Update(gameTime); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/15_audio_controller/snippets/game1.cs b/articles/tutorials/building_2d_games/15_audio_controller/snippets/game1.cs new file mode 100644 index 00000000..77c8d393 --- /dev/null +++ b/articles/tutorials/building_2d_games/15_audio_controller/snippets/game1.cs @@ -0,0 +1,366 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using Microsoft.Xna.Framework.Media; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; +using MonoGameLibrary.Input; + +namespace DungeonSlime; + +public class Game1 : Core +{ + // Defines the slime animated sprite. + private AnimatedSprite _slime; + + // Defines the bat animated sprite. + private AnimatedSprite _bat; + + // Tracks the position of the slime. + private Vector2 _slimePosition; + + // Speed multiplier when moving. + private const float MOVEMENT_SPEED = 5.0f; + + // Tracks the position of the bat. + private Vector2 _batPosition; + + // Tracks the velocity of the bat. + private Vector2 _batVelocity; + + // Defines the tilemap to draw. + private Tilemap _tilemap; + + // Defines the bounds of the room that the slime and bat are contained within. + private Rectangle _roomBounds; + + // The sound effect to play when the bat bounces off the edge of the screen. + private SoundEffect _bounceSoundEffect; + + // The sound effect to play when the slime eats a bat. + private SoundEffect _collectSoundEffect; + + // The background theme song + private Song _themeSong; + + public Game1() : base("Dungeon Slime", 1280, 720, false) + { + + } + + protected override void Initialize() + { + base.Initialize(); + + Rectangle screenBounds = GraphicsDevice.PresentationParameters.Bounds; + + _roomBounds = new Rectangle( + (int)_tilemap.TileWidth, + (int)_tilemap.TileHeight, + screenBounds.Width - (int)_tilemap.TileWidth * 2, + screenBounds.Height - (int)_tilemap.TileHeight * 2 + ); + + // Initial slime position will be the center tile of the tile map. + int centerRow = _tilemap.Rows / 2; + int centerColumn = _tilemap.Columns / 2; + _slimePosition = new Vector2(centerColumn * _tilemap.TileWidth, centerRow * _tilemap.TileHeight); + + // Initial bat position will the in the top left corner of the room + _batPosition = new Vector2(_roomBounds.Left, _roomBounds.Top); + + // Assign the initial random velocity to the bat. + AssignRandomBatVelocity(); + + // Start playing the background music + Audio.PlaySong(_themeSong); + } + + protected override void LoadContent() + { + // Create the texture atlas from the XML configuration file + TextureAtlas atlas = TextureAtlas.FromFile(Content, "images/atlas-definition.xml"); + + // Create the slime animated sprite from the atlas. + _slime = atlas.CreateAnimatedSprite("slime-animation"); + _slime.Scale = new Vector2(4.0f, 4.0f); + + // Create the bat animated sprite from the atlas. + _bat = atlas.CreateAnimatedSprite("bat-animation"); + _bat.Scale = new Vector2(4.0f, 4.0f); + + // Create the tilemap from the XML configuration file. + _tilemap = Tilemap.FromFile(Content, "images/tilemap-definition.xml"); + _tilemap.Scale = new Vector2(4.0f, 4.0f); + + // Load the bounce sound effect + _bounceSoundEffect = Content.Load("audio/bounce"); + + // Load the collect sound effect + _collectSoundEffect = Content.Load("audio/collect"); + + // Load the background theme music + _themeSong = Content.Load("audio/theme"); + } + + protected override void Update(GameTime gameTime) + { + if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape)) + Exit(); + + // Update the slime animated sprite. + _slime.Update(gameTime); + + // Update the bat animated sprite. + _bat.Update(gameTime); + + // Check for keyboard input and handle it. + CheckKeyboardInput(); + + // Check for gamepad input and handle it. + CheckGamePadInput(); + + // Creating a bounding circle for the slime + Circle slimeBounds = new Circle( + (int)(_slimePosition.X + (_slime.Width * 0.5f)), + (int)(_slimePosition.Y + (_slime.Height * 0.5f)), + (int)(_slime.Width * 0.5f) + ); + + // Use distance based checks to determine if the slime is within the + // bounds of the game screen, and if it is outside that screen edge, + // move it back inside. + if (slimeBounds.Left < _roomBounds.Left) + { + _slimePosition.X = _roomBounds.Left; + } + else if (slimeBounds.Right > _roomBounds.Right) + { + _slimePosition.X = _roomBounds.Right - _slime.Width; + } + + if (slimeBounds.Top < _roomBounds.Top) + { + _slimePosition.Y = _roomBounds.Top; + } + else if (slimeBounds.Bottom > _roomBounds.Bottom) + { + _slimePosition.Y = _roomBounds.Bottom - _slime.Height; + } + + // Calculate the new position of the bat based on the velocity + Vector2 newBatPosition = _batPosition + _batVelocity; + + // Create a bounding circle for the bat + Circle batBounds = new Circle( + (int)(newBatPosition.X + (_bat.Width * 0.5f)), + (int)(newBatPosition.Y + (_bat.Height * 0.5f)), + (int)(_bat.Width * 0.5f) + ); + + Vector2 normal = Vector2.Zero; + + // Use distance based checks to determine if the bat is within the + // bounds of the game screen, and if it is outside that screen edge, + // reflect it about the screen edge normal + if (batBounds.Left < _roomBounds.Left) + { + normal.X = Vector2.UnitX.X; + newBatPosition.X = _roomBounds.Left; + } + else if (batBounds.Right > _roomBounds.Right) + { + normal.X = -Vector2.UnitX.X; + newBatPosition.X = _roomBounds.Right - _bat.Width; + } + + if (batBounds.Top < _roomBounds.Top) + { + normal.Y = Vector2.UnitY.Y; + newBatPosition.Y = _roomBounds.Top; + } + else if (batBounds.Bottom > _roomBounds.Bottom) + { + normal.Y = -Vector2.UnitY.Y; + newBatPosition.Y = _roomBounds.Bottom - _bat.Height; + } + + // If the normal is anything but Vector2.Zero, this means the bat had + // moved outside the screen edge so we should reflect it about the + // normal. + if (normal != Vector2.Zero) + { + _batVelocity = Vector2.Reflect(_batVelocity, normal); + + // Play the bounce sound effect + Audio.PlaySoundEffect(_bounceSoundEffect); + } + + _batPosition = newBatPosition; + + if (slimeBounds.Intersects(batBounds)) + { + // Choose a random row and column based on the total number of each + int column = Random.Shared.Next(1, _tilemap.Columns - 1); + int row = Random.Shared.Next(1, _tilemap.Rows - 1); + + // Change the bat position by setting the x and y values equal to + // the column and row multiplied by the width and height. + _batPosition = new Vector2(column * _bat.Width, row * _bat.Height); + + // Assign a new random velocity to the bat + AssignRandomBatVelocity(); + + // Play the collect sound effect + Audio.PlaySoundEffect(_collectSoundEffect); + } + + base.Update(gameTime); + } + + private void AssignRandomBatVelocity() + { + // Generate a random angle + float angle = (float)(Random.Shared.NextDouble() * Math.PI * 2); + + // Convert angle to a direction vector + float x = (float)Math.Cos(angle); + float y = (float)Math.Sin(angle); + Vector2 direction = new Vector2(x, y); + + // Multiply the direction vector by the movement speed + _batVelocity = direction * MOVEMENT_SPEED; + } + + private void CheckKeyboardInput() + { + // If the space key is held down, the movement speed increases by 1.5 + float speed = MOVEMENT_SPEED; + if (Input.Keyboard.IsKeyDown(Keys.Space)) + { + speed *= 1.5f; + } + + // If the W or Up keys are down, move the slime up on the screen. + if (Input.Keyboard.IsKeyDown(Keys.W) || Input.Keyboard.IsKeyDown(Keys.Up)) + { + _slimePosition.Y -= speed; + } + + // if the S or Down keys are down, move the slime down on the screen. + if (Input.Keyboard.IsKeyDown(Keys.S) || Input.Keyboard.IsKeyDown(Keys.Down)) + { + _slimePosition.Y += speed; + } + + // If the A or Left keys are down, move the slime left on the screen. + if (Input.Keyboard.IsKeyDown(Keys.A) || Input.Keyboard.IsKeyDown(Keys.Left)) + { + _slimePosition.X -= speed; + } + + // If the D or Right keys are down, move the slime right on the screen. + if (Input.Keyboard.IsKeyDown(Keys.D) || Input.Keyboard.IsKeyDown(Keys.Right)) + { + _slimePosition.X += speed; + } + + // If the M key is pressed, toggle mute state for audio. + if (Input.Keyboard.WasKeyJustPressed(Keys.M)) + { + Audio.ToggleMute(); + } + + // If the + button is pressed, increase the volume. + if (Input.Keyboard.WasKeyJustPressed(Keys.OemPlus)) + { + Audio.SongVolume += 0.1f; + Audio.SoundEffectVolume += 0.1f; + } + + // If the - button was pressed, decrease the volume. + if (Input.Keyboard.WasKeyJustPressed(Keys.OemMinus)) + { + Audio.SongVolume -= 0.1f; + Audio.SoundEffectVolume -= 0.1f; + } + } + + private void CheckGamePadInput() + { + GamePadInfo gamePadOne = Input.GamePads[(int)PlayerIndex.One]; + + // If the A button is held down, the movement speed increases by 1.5 + // and the gamepad vibrates as feedback to the player. + float speed = MOVEMENT_SPEED; + if (gamePadOne.IsButtonDown(Buttons.A)) + { + speed *= 1.5f; + GamePad.SetVibration(PlayerIndex.One, 1.0f, 1.0f); + } + else + { + GamePad.SetVibration(PlayerIndex.One, 0.0f, 0.0f); + } + + // Check thumbstick first since it has priority over which gamepad input + // is movement. It has priority since the thumbstick values provide a + // more granular analog value that can be used for movement. + if (gamePadOne.LeftThumbStick != Vector2.Zero) + { + _slimePosition.X += gamePadOne.LeftThumbStick.X * speed; + _slimePosition.Y -= gamePadOne.LeftThumbStick.Y * speed; + } + else + { + // If DPadUp is down, move the slime up on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadUp)) + { + _slimePosition.Y -= speed; + } + + // If DPadDown is down, move the slime down on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadDown)) + { + _slimePosition.Y += speed; + } + + // If DPapLeft is down, move the slime left on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadLeft)) + { + _slimePosition.X -= speed; + } + + // If DPadRight is down, move the slime right on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadRight)) + { + _slimePosition.X += speed; + } + } + } + + protected override void Draw(GameTime gameTime) + { + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + // Draw the tilemap + _tilemap.Draw(SpriteBatch); + + // Draw the slime sprite. + _slime.Draw(SpriteBatch, _slimePosition); + + // Draw the bat sprite. + _bat.Draw(SpriteBatch, _batPosition); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/15_audio_controller/videos/gameplay.webm b/articles/tutorials/building_2d_games/15_audio_controller/videos/gameplay.webm new file mode 100644 index 00000000..1c1981cb Binary files /dev/null and b/articles/tutorials/building_2d_games/15_audio_controller/videos/gameplay.webm differ diff --git a/articles/tutorials/building_2d_games/16_working_with_spritefonts/files/04B_30.ttf b/articles/tutorials/building_2d_games/16_working_with_spritefonts/files/04B_30.ttf new file mode 100644 index 00000000..4b93740c Binary files /dev/null and b/articles/tutorials/building_2d_games/16_working_with_spritefonts/files/04B_30.ttf differ diff --git a/articles/tutorials/building_2d_games/16_working_with_spritefonts/images/font_added.png b/articles/tutorials/building_2d_games/16_working_with_spritefonts/images/font_added.png new file mode 100644 index 00000000..cc9d7f7c Binary files /dev/null and b/articles/tutorials/building_2d_games/16_working_with_spritefonts/images/font_added.png differ diff --git a/articles/tutorials/building_2d_games/16_working_with_spritefonts/index.md b/articles/tutorials/building_2d_games/16_working_with_spritefonts/index.md new file mode 100644 index 00000000..f13de652 --- /dev/null +++ b/articles/tutorials/building_2d_games/16_working_with_spritefonts/index.md @@ -0,0 +1,273 @@ +--- +title: "Chapter 16: Working with SpriteFonts" +description: "Learn how to create and use SpriteFonts to render text in your MonoGame project, including loading custom fonts and controlling text appearance." +--- + +In [Chapter 06](../06_working_with_textures/index.md), you learned how to load and render textures to display sprites in your game. While images are essential for visual elements, most games also need text for things like scores, player instructions, dialogue, and UI elements. MonoGame provides the [**SpriteFont**](xref:Microsoft.Xna.Framework.Graphics.SpriteFont) class to handle text rendering, which works together with the familiar [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch) we have been using for drawing textures. + +In this chapter, you will: + +- Learn how MonoGame handles text rendering with SpriteFonts. +- Create `SpriteFont` description using the MGCB Editor. +- Load custom fonts for use in your game. +- Render text using various parameters to control appearance. +- Implement text rendering in our game. + +We will first start by understanding how text rendering works in MonoGame. + +## Understanding SpriteFonts + +MonoGame processes fonts through the content pipeline to create a texture atlas of font characters. MonoGame uses the texture atlas approach rather than directly using system fonts for several important reasons: + +- **Cross-platform Compatibility**: System fonts cannot be guaranteed to exist on all platforms. +- **Consistency**: Ensures that the text appears the same across all platforms. +- **GPU Rendering**: Graphics cards do not understand font formats directly; they can only render textures. +- **Performance**: Pre-rendering the glyphs to a texture atlas allow for faster rendering at runtime with no texture swapping. + +A [**SpriteFont**](xref:Microsoft.Xna.Framework.Graphics.SpriteFont) in MonoGame consists of: + +1. A texture atlas containing pre-rendered glyphs (characters). +2. Data that tracks the position, size, and spacing of each character. +3. Kerning information for adjusting spacing between specific character pairs. + +The texture atlas approach means fonts are rendered as sprites, using the same [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch) system you learned about for drawing textures ([Chapter 7: Optimized Texture Rendering](../07_optimizing_texture_rendering/index.md)). When you draw text, MonoGame is actually drawing small portions of the texture atlas for each character assembled together to form complete words and sentences. + +## Creating a SpriteFont Description + +To use text in your game, you first need to create a SpriteFont Description file and process it through the Content Pipeline, thankfully the MGCB Editor makes this process straightforward. + +For example, in the MGCB Editor: + +1. Right-click the content project node where the SpriteFont Description will be created and choose *Add* > *New Item...*. +2. Select `SpriteFont Description (.spritefont)` from the options. +3. Specify a name for the SpriteFont Description file and click `Create`. + +This will create a default SpriteFont Description file that look something like this: + +[!code-xml[](./snippets/spritefont_description.spritefont)] + +When creating a SpriteFont Description for your game, you will need to make several important decisions about font selection, size, formatting, and licensing. The following sections will guide you through customizing the SpriteFont Description using these considerations. + +### Customizing the SpriteFont + +The SpriteFont Description file allows you to customize various aspects of how the font will be processed and appear in your game. Here are the key elements you can modify: + +#### FontName + +The `` element specifies which font to use. By default, it references "Arial". When a font name is specified just by name like this, it is required that the font be installed on the system where the content is built. + +> [!IMPORTANT] +> MonoGame recommends changing the default Arial font if you are targeting any platforms other than Windows. Arial is a legacy from XNA and is only guaranteed to be available in Windows builds. As an alternative, MonoGame currently recommends using [Roboto](https://fonts.google.com/specimen/Roboto). + +Alternatively, for better portability across development environments, it is recommended instead to directly reference a TrueType (.ttf) or OpenType (.otf) font file. To do this + +1. Download or locate a TTF or OTF font file. +2. Place it in the **same folder** as the `.spritefont` file. + + > [!IMPORTANT] + > You place the font file in the **same folder** as the `.spritefont` file directly, not through the MGCB Editor. + +3. Update the `` element to include the exact filename with extension. + +> [!TIP] +> Use fonts with permissive licenses (like [SIL Open Font License](https://openfontlicense.org/)) to ensure you can **legally** use them in your game. +> +> **Always check the license of any font you use!** + +#### Size + +The `` element controls the font size in points. While it might seem straightforward, font sizing requires consideration and can be dependent on several factors. When choosing a font size, consider: + +- **Resolution impact**: Fonts that look good at 1080p may appear too small at 4K or too large at 720p. +- **Font style**: Pixel fonts look best with small sizes to preserve crispness. +- **Use case**: Different UI elements may require different sizes for proper hierarchy. + +You may want to create multiple SpriteFont Description files for different use cases in your game such as: + +- A larger font for headings and titles. +- A medium-sized font for standard UI elements. +- A smaller font for detailed information. + +> [!TIP] +> Creating multiple SpriteFont Description files, however, can remove some of the benefits of fonts being a texture atlas since you will now have multiple atlases for each size. You will also now have multiple assets to manage both as asset files and references in code. +> +> An alternative approach is to create a single SpriteFont Description with a larger than needed size font, then scale it down during runtime in the game. This approach allows you to maintain the single SpriteFont Description file and single texture atlas, however, the size of the texture atlas will now be larger. +> +> There are tradeoffs to each approach and you should choose the one that works best for your game. + +#### Spacing + +The `` element adjusts the space between characters. The default value of 0 uses the font's built-in spacing. Positive values increase spacing, while negative values (though rarely used) can decrease it. + +#### UseKerning + +The `` element determines whether to use kerning information from the font. Kerning adjusts the spacing between specific pairs of characters for more visually pleasing results. For most fonts, you will want to leave this as `true`. + +> [!NOTE] +> While kerning typically improves text appearance, some fonts (including Arial) may not respond optimally to kerning adjustments. If you notice unusual character spacing with a particular font, try setting this value to `false`. + +#### Style + +The ` + + + + ~ + + + + diff --git a/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/center_example.cs b/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/center_example.cs new file mode 100644 index 00000000..ed01e160 --- /dev/null +++ b/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/center_example.cs @@ -0,0 +1,27 @@ +// The text to draw. +string message = "Hello, MonoGame!"; + +// Measure the size of the message to get the text dimensions. +Vector2 textSize = font.MeasureString(message); + +// Set the origin to the center of the text dimensions +Vector2 origin = textSize * 0.5f; + +// Position will be the center of the screen +Vector2 position = new Vector2( + GraphicsDevice.PresentationParameters.BackBufferWidth, + GraphicsDevice.PresentationParameters.BackBufferHeight +) * 0.5f; + +// Draw centered text +_spriteBatch.DrawString( + font, // font + message, // text + position, // position + Color.White, // color + 0.0f, // rotation + origin, // origin + 1.0f, // scale + SpriteEffects.None, // effects + 0.0f // layerDepth +); diff --git a/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/drawstring_basic.cs b/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/drawstring_basic.cs new file mode 100644 index 00000000..10296585 --- /dev/null +++ b/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/drawstring_basic.cs @@ -0,0 +1,6 @@ +_spriteBatch.DrawString( + font, // font + "Hello, MonoGame!", // text + Vector2.Zero, // position + Color.White // color +); \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/drawstring_full.cs b/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/drawstring_full.cs new file mode 100644 index 00000000..1fcd1150 --- /dev/null +++ b/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/drawstring_full.cs @@ -0,0 +1,11 @@ +_spriteBatch.DrawString( + font, // font + "Hello, MonoGame!", // text + Vector2.Zero, // position + Color.White, // color + 0.0f, // rotation + Vector2.Zero, // origin + Vector2.One, // scale + SpriteEffects.None, // effects + 0.0f // layerDepth +); \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/game1.cs b/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/game1.cs new file mode 100644 index 00000000..458f4a45 --- /dev/null +++ b/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/game1.cs @@ -0,0 +1,406 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using Microsoft.Xna.Framework.Media; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; +using MonoGameLibrary.Input; + +namespace DungeonSlime; + +public class Game1 : Core +{ + // Defines the slime animated sprite. + private AnimatedSprite _slime; + + // Defines the bat animated sprite. + private AnimatedSprite _bat; + + // Tracks the position of the slime. + private Vector2 _slimePosition; + + // Speed multiplier when moving. + private const float MOVEMENT_SPEED = 5.0f; + + // Tracks the position of the bat. + private Vector2 _batPosition; + + // Tracks the velocity of the bat. + private Vector2 _batVelocity; + + // Defines the tilemap to draw. + private Tilemap _tilemap; + + // Defines the bounds of the room that the slime and bat are contained within. + private Rectangle _roomBounds; + + // The sound effect to play when the bat bounces off the edge of the screen. + private SoundEffect _bounceSoundEffect; + + // The sound effect to play when the slime eats a bat. + private SoundEffect _collectSoundEffect; + + // The background theme song + private Song _themeSong; + + // The SpriteFont Description used to draw text + private SpriteFont _font; + + // Tracks the players score. + private int _score; + + // Defines the position to draw the score text at. + private Vector2 _scoreTextPosition; + + // Defines the origin used when drawing the score text. + private Vector2 _scoreTextOrigin; + + + public Game1() : base("Dungeon Slime", 1280, 720, false) + { + + } + + protected override void Initialize() + { + base.Initialize(); + + Rectangle screenBounds = GraphicsDevice.PresentationParameters.Bounds; + + _roomBounds = new Rectangle( + (int)_tilemap.TileWidth, + (int)_tilemap.TileHeight, + screenBounds.Width - (int)_tilemap.TileWidth * 2, + screenBounds.Height - (int)_tilemap.TileHeight * 2 + ); + + // Initial slime position will be the center tile of the tile map. + int centerRow = _tilemap.Rows / 2; + int centerColumn = _tilemap.Columns / 2; + _slimePosition = new Vector2(centerColumn * _tilemap.TileWidth, centerRow * _tilemap.TileHeight); + + // Initial bat position will the in the top left corner of the room + _batPosition = new Vector2(_roomBounds.Left, _roomBounds.Top); + + // Assign the initial random velocity to the bat. + AssignRandomBatVelocity(); + + // Start playing the background music + Audio.PlaySong(_themeSong); + + // Set the position of the score text to align to the left edge of the + // room bounds, and to vertically be at the center of the first tile. + _scoreTextPosition = new Vector2(_roomBounds.Left, _tilemap.TileHeight * 0.5f); + + // Set the origin of the text so it is left-centered. + float scoreTextYOrigin = _font.MeasureString("Score").Y * 0.5f; + _scoreTextOrigin = new Vector2(0, scoreTextYOrigin); + } + + protected override void LoadContent() + { + // Create the texture atlas from the XML configuration file + TextureAtlas atlas = TextureAtlas.FromFile(Content, "images/atlas-definition.xml"); + + // Create the slime animated sprite from the atlas. + _slime = atlas.CreateAnimatedSprite("slime-animation"); + _slime.Scale = new Vector2(4.0f, 4.0f); + + // Create the bat animated sprite from the atlas. + _bat = atlas.CreateAnimatedSprite("bat-animation"); + _bat.Scale = new Vector2(4.0f, 4.0f); + + // Create the tilemap from the XML configuration file. + _tilemap = Tilemap.FromFile(Content, "images/tilemap-definition.xml"); + _tilemap.Scale = new Vector2(4.0f, 4.0f); + + // Load the bounce sound effect + _bounceSoundEffect = Content.Load("audio/bounce"); + + // Load the collect sound effect + _collectSoundEffect = Content.Load("audio/collect"); + + // Load the background theme music + _themeSong = Content.Load("audio/theme"); + + // Load the font + _font = Content.Load("fonts/04B_30"); + } + + protected override void Update(GameTime gameTime) + { + if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape)) + Exit(); + + // Update the slime animated sprite. + _slime.Update(gameTime); + + // Update the bat animated sprite. + _bat.Update(gameTime); + + // Check for keyboard input and handle it. + CheckKeyboardInput(); + + // Check for gamepad input and handle it. + CheckGamePadInput(); + + // Creating a bounding circle for the slime + Circle slimeBounds = new Circle( + (int)(_slimePosition.X + (_slime.Width * 0.5f)), + (int)(_slimePosition.Y + (_slime.Height * 0.5f)), + (int)(_slime.Width * 0.5f) + ); + + // Use distance based checks to determine if the slime is within the + // bounds of the game screen, and if it is outside that screen edge, + // move it back inside. + if (slimeBounds.Left < _roomBounds.Left) + { + _slimePosition.X = _roomBounds.Left; + } + else if (slimeBounds.Right > _roomBounds.Right) + { + _slimePosition.X = _roomBounds.Right - _slime.Width; + } + + if (slimeBounds.Top < _roomBounds.Top) + { + _slimePosition.Y = _roomBounds.Top; + } + else if (slimeBounds.Bottom > _roomBounds.Bottom) + { + _slimePosition.Y = _roomBounds.Bottom - _slime.Height; + } + + // Calculate the new position of the bat based on the velocity + Vector2 newBatPosition = _batPosition + _batVelocity; + + // Create a bounding circle for the bat + Circle batBounds = new Circle( + (int)(newBatPosition.X + (_bat.Width * 0.5f)), + (int)(newBatPosition.Y + (_bat.Height * 0.5f)), + (int)(_bat.Width * 0.5f) + ); + + Vector2 normal = Vector2.Zero; + + // Use distance based checks to determine if the bat is within the + // bounds of the game screen, and if it is outside that screen edge, + // reflect it about the screen edge normal + if (batBounds.Left < _roomBounds.Left) + { + normal.X = Vector2.UnitX.X; + newBatPosition.X = _roomBounds.Left; + } + else if (batBounds.Right > _roomBounds.Right) + { + normal.X = -Vector2.UnitX.X; + newBatPosition.X = _roomBounds.Right - _bat.Width; + } + + if (batBounds.Top < _roomBounds.Top) + { + normal.Y = Vector2.UnitY.Y; + newBatPosition.Y = _roomBounds.Top; + } + else if (batBounds.Bottom > _roomBounds.Bottom) + { + normal.Y = -Vector2.UnitY.Y; + newBatPosition.Y = _roomBounds.Bottom - _bat.Height; + } + + // If the normal is anything but Vector2.Zero, this means the bat had + // moved outside the screen edge so we should reflect it about the + // normal. + if (normal != Vector2.Zero) + { + _batVelocity = Vector2.Reflect(_batVelocity, normal); + + // Play the bounce sound effect + Audio.PlaySoundEffect(_bounceSoundEffect); + } + + _batPosition = newBatPosition; + + if (slimeBounds.Intersects(batBounds)) + { + // Choose a random row and column based on the total number of each + int column = Random.Shared.Next(1, _tilemap.Columns - 1); + int row = Random.Shared.Next(1, _tilemap.Rows - 1); + + // Change the bat position by setting the x and y values equal to + // the column and row multiplied by the width and height. + _batPosition = new Vector2(column * _bat.Width, row * _bat.Height); + + // Assign a new random velocity to the bat + AssignRandomBatVelocity(); + + // Play the collect sound effect + Audio.PlaySoundEffect(_collectSoundEffect); + + // Increase the player's score. + _score += 100; + } + + base.Update(gameTime); + } + + private void AssignRandomBatVelocity() + { + // Generate a random angle + float angle = (float)(Random.Shared.NextDouble() * Math.PI * 2); + + // Convert angle to a direction vector + float x = (float)Math.Cos(angle); + float y = (float)Math.Sin(angle); + Vector2 direction = new Vector2(x, y); + + // Multiply the direction vector by the movement speed + _batVelocity = direction * MOVEMENT_SPEED; + } + + private void CheckKeyboardInput() + { + // If the space key is held down, the movement speed increases by 1.5 + float speed = MOVEMENT_SPEED; + if (Input.Keyboard.IsKeyDown(Keys.Space)) + { + speed *= 1.5f; + } + + // If the W or Up keys are down, move the slime up on the screen. + if (Input.Keyboard.IsKeyDown(Keys.W) || Input.Keyboard.IsKeyDown(Keys.Up)) + { + _slimePosition.Y -= speed; + } + + // if the S or Down keys are down, move the slime down on the screen. + if (Input.Keyboard.IsKeyDown(Keys.S) || Input.Keyboard.IsKeyDown(Keys.Down)) + { + _slimePosition.Y += speed; + } + + // If the A or Left keys are down, move the slime left on the screen. + if (Input.Keyboard.IsKeyDown(Keys.A) || Input.Keyboard.IsKeyDown(Keys.Left)) + { + _slimePosition.X -= speed; + } + + // If the D or Right keys are down, move the slime right on the screen. + if (Input.Keyboard.IsKeyDown(Keys.D) || Input.Keyboard.IsKeyDown(Keys.Right)) + { + _slimePosition.X += speed; + } + + // If the M key is pressed, toggle mute state for audio. + if (Input.Keyboard.WasKeyJustPressed(Keys.M)) + { + Audio.ToggleMute(); + } + + // If the + button is pressed, increase the volume. + if (Input.Keyboard.WasKeyJustPressed(Keys.OemPlus)) + { + Audio.SongVolume += 0.1f; + Audio.SoundEffectVolume += 0.1f; + } + + // If the - button was pressed, decrease the volume. + if (Input.Keyboard.WasKeyJustPressed(Keys.OemMinus)) + { + Audio.SongVolume -= 0.1f; + Audio.SoundEffectVolume -= 0.1f; + } + } + + private void CheckGamePadInput() + { + GamePadInfo gamePadOne = Input.GamePads[(int)PlayerIndex.One]; + + // If the A button is held down, the movement speed increases by 1.5 + // and the gamepad vibrates as feedback to the player. + float speed = MOVEMENT_SPEED; + if (gamePadOne.IsButtonDown(Buttons.A)) + { + speed *= 1.5f; + GamePad.SetVibration(PlayerIndex.One, 1.0f, 1.0f); + } + else + { + GamePad.SetVibration(PlayerIndex.One, 0.0f, 0.0f); + } + + // Check thumbstick first since it has priority over which gamepad input + // is movement. It has priority since the thumbstick values provide a + // more granular analog value that can be used for movement. + if (gamePadOne.LeftThumbStick != Vector2.Zero) + { + _slimePosition.X += gamePadOne.LeftThumbStick.X * speed; + _slimePosition.Y -= gamePadOne.LeftThumbStick.Y * speed; + } + else + { + // If DPadUp is down, move the slime up on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadUp)) + { + _slimePosition.Y -= speed; + } + + // If DPadDown is down, move the slime down on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadDown)) + { + _slimePosition.Y += speed; + } + + // If DPapLeft is down, move the slime left on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadLeft)) + { + _slimePosition.X -= speed; + } + + // If DPadRight is down, move the slime right on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadRight)) + { + _slimePosition.X += speed; + } + } + } + + protected override void Draw(GameTime gameTime) + { + // Clear the back buffer. + GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + // Draw the tilemap + _tilemap.Draw(SpriteBatch); + + // Draw the slime sprite. + _slime.Draw(SpriteBatch, _slimePosition); + + // Draw the bat sprite. + _bat.Draw(SpriteBatch, _batPosition); + + // Draw the score + SpriteBatch.DrawString( + _font, // spriteFont + $"Score: {_score}", // text + _scoreTextPosition, // position + Color.White, // color + 0.0f, // rotation + _scoreTextOrigin, // origin + 1.0f, // scale + SpriteEffects.None, // effects + 0.0f // layerDepth + ); + + // Always end the sprite batch when finished. + SpriteBatch.End(); + + base.Draw(gameTime); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/measurestring.cs b/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/measurestring.cs new file mode 100644 index 00000000..1e7d5dec --- /dev/null +++ b/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/measurestring.cs @@ -0,0 +1,5 @@ +// The text to measure +string message = "Hello, MonoGame!"; + +// Measure the size of the message to get the text dimensions. +Vector2 textSize = font.MeasureString(message); \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/spritefont.xml b/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/spritefont.xml new file mode 100644 index 00000000..4ba3ae6e --- /dev/null +++ b/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/spritefont.xml @@ -0,0 +1,60 @@ + + + + + + + Arial + + + 16 + + + 0 + + + true + + + + + + + + + + + + ~ + + + + diff --git a/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/spritefont_description.spritefont b/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/spritefont_description.spritefont new file mode 100644 index 00000000..bd33ecf3 --- /dev/null +++ b/articles/tutorials/building_2d_games/16_working_with_spritefonts/snippets/spritefont_description.spritefont @@ -0,0 +1,60 @@ + + + + + + + Arial + + + 12 + + + 0 + + + true + + + + + + + + + + + + ~ + + + + diff --git a/articles/tutorials/building_2d_games/16_working_with_spritefonts/videos/gameplay.webm b/articles/tutorials/building_2d_games/16_working_with_spritefonts/videos/gameplay.webm new file mode 100644 index 00000000..5838dad3 Binary files /dev/null and b/articles/tutorials/building_2d_games/16_working_with_spritefonts/videos/gameplay.webm differ diff --git a/articles/tutorials/building_2d_games/17_scenes/files/04B_30_5x.spritefont b/articles/tutorials/building_2d_games/17_scenes/files/04B_30_5x.spritefont new file mode 100644 index 00000000..7d56d01f --- /dev/null +++ b/articles/tutorials/building_2d_games/17_scenes/files/04B_30_5x.spritefont @@ -0,0 +1,60 @@ + + + + + + + 04B_30.ttf + + + 87.5 + + + 0 + + + true + + + + + + + + + + + + ~ + + + + diff --git a/articles/tutorials/building_2d_games/17_scenes/images/font_added.png b/articles/tutorials/building_2d_games/17_scenes/images/font_added.png new file mode 100644 index 00000000..ee964755 Binary files /dev/null and b/articles/tutorials/building_2d_games/17_scenes/images/font_added.png differ diff --git a/articles/tutorials/building_2d_games/17_scenes/index.md b/articles/tutorials/building_2d_games/17_scenes/index.md new file mode 100644 index 00000000..57658750 --- /dev/null +++ b/articles/tutorials/building_2d_games/17_scenes/index.md @@ -0,0 +1,400 @@ +--- +title: "Chapter 17: Scene Management" +description: "Learn how to implement scene management to handle different game screens like menus, gameplay, and transitions between scenes." +--- + +In game development, a scene (sometimes called a screen or state) represents a distinct section of the game. Each scene typically has its own update and draw logic, as well as its own set of game objects. Common examples of scenes include title screens, menus, gameplay screens, game over screens, and more. Scenes help organize the game's code by separating different parts of the game into self-contained modules. This makes the code more manageable as the game grows in complexity and offers several advantages: + +1. **Improved organization**: Each scene contains only the code and assets relevant to that part of the game. +2. **Memory management**: Load assets only when needed and unload them when leaving a scene. +3. **Simplified state handling**: Each scene maintains its own state without affecting others. +4. **Code reusability**: Create reusable scene templates for common game screens. + +Our game logic is currently contained within the single `Game1` class. Adding more screens to it would make the code harder to manage, so instead we need to start thinking about breaking it down into scenes. + +In this chapter, you will: + +- Learn the concept of scene management and its benefits +- Create a base Scene class with a consistent lifecycle +- Implement scene transitions using a manager +- Create a title scene and gameplay scene for our game +- Refactor our existing game to use the scene system + +We will being by first defining the lifecycle of a scene that will be followed. + +## Scene Lifecycle + +In Chapter 03, you learned the basic [lifecycle of the `Game` class](../03_the_game1_file/index.md#exploring-the-game1-class). To be consistent, we can borrow from this lifecycle and adapt it for our scenes. The order of operations for this lifecycle will be: + +1. A scene is created and set as the active scene. +2. The first screen is made active and is initialized and content loaded. +3. The active scene is updated and drawn each cycle. +4. When transitioning to a new scene, or when the scene ends: + 1. The current scene is unloaded and disposed of. + 2. The new scene is initialized and content loaded. + 3. The new scene becomes the active scene and the cycle begins again until the game is told to exit. + +## The Scene Base Class + +The base `Scene` class is an abstract class for scenes that provides common functionality for all scenes. In our actual game, we will create concrete implementations of this, like a title scene. + +To get started, in the *MonoGameLibrary* project: + +1. Create a new folder named `Scenes`. +2. Add a new class file named `Scene.cs` to the `Scenes` folder you just created. +3. Add the following code as the initial structure for the class: + + [!code-csharp[](./snippets/scene.cs#declaration)] + + > [!NOTE] + > Just like with the `AudioController` in [Chapter 15](../15_audio_controller/index.md#audiocontroller-idisposable-implementation), each `Scene` implements the `IDisposable` interface. This provides a standardized in method to release the resources held by a scene when it is no longer needed. + +### Scene Properties + +Add the following properties to the `Scene` class: + +[!code-csharp[](./snippets/scene.cs#properties)] + +- The `Content` property is the scene's personal [**ContentManager**](xref:Microsoft.Xna.Framework.Content.ContentManager) that can be used to load scene specific content that will be unloaded when the scene ends. This helps manage memory usage by only loading what is needed for a specific scene. +- The `IsDisposed` property is used to track if the scene has been disposed of since it implements the `IDisposable` interface. + +### Scene Constructor + +Add the following constructor and finalizer to the `Scene` class: + +[!code-csharp[](./snippets/scene.cs#ctors)] + +- The constructor initializes the scene's content manager and sets the root folder to match that of the base game's content manager. +- The finalizer is called by the garbage collector automatically when a scene object is collected which just calls the `Dispose` method to ensure resources are disposed of properly. + +### Scene Methods + +Add the following methods to the `Scene` class: + +[!code-csharp[](./snippets/scene.cs#methods)] + +These methods are setup similar to how the `Game` class works to keep the workflow consistent: + +- `Initialize` is called only once when the scene becomes the active scene. It can be overridden by the derived class to provide scene specific initialization logic. It also calls the `LoadContent` method the same way the `Game` class is done for consistency. +- `LoadContent` is called only once, at the end of the `Initialize` method. It can be overridden by the derived class to load scene specific content. +- `UnloadContent` is called only once when a scene is ending due to a transition to a new scene. It can be overridden by the derived class to perform unloading of any scene specific content. +- `Update` is called once at the start of every game cycle. It can be overridden to provide the update logic for the scene. +- `Draw` is called once every game cycle, directly after `Update`. It can be overridden to provide the draw logic for the scene. + +#### IDisposable Implementation + +Add the following methods to the `Scene` class to complete the implementation of the `IDisposable` interface: + +[!code-csharp[](./snippets/scene.cs#disposable)] + +This completes our Base scene implementation that we will use to create actual scenes from in our project, next we need a manager that will organise the screens for use in the game. + +## Scene Management + +With the base `Scene` class defined, the `Core` class needs to be updated to handle management of the scenes, including update, drawing, and changing scenes. Open the `Core.cs` file in the *MonoGameLibrary* project and make the following changes: + +[!code-csharp[](./snippets/core.cs?highlight=8,21-25,144-155,160-169,171-179,181-205)] + +The key changes here are: + +1. The `using MonoGameLibrary.Scenes;` using directive was added so we have access to the `Scene` class. +2. The fields `_activeScene` and `_nextScene` were added to track which scene is currently active and which scene, if any, to switch to. +3. In `Update`: + 1. A check is made to see if there is a next scene, and if so, `TransitionScene` is called to gracefully switch from the current to the next. + 2. A check is made to see if there is an active scene, and if so, updates it. +4. An override for the `Draw` method was added where a check is made to see if there is an active scene, and if so, draws it. +5. The `ChangeScene` method was added which can be called when we want to tell the core to change from one scene to another one. +6. The `TransitionScene` method was add that gracefully transitions from the current scene to the next scene by + 1. A check is made to see if there is an active scene, and if so, disposes it. + 2. The garbage collector is told to perform a collection to clear out memory from the disposal of the current scene. + 3. The next scene is set as the current scene. + 4. A check is made to see if there is now a current scene, and if so, initializes it. + +> [!TIP] +> Notice that we use a two-step process for scene transitions with separate `_activeScene` and `_nextScene` fields. This design allows the current scene to complete its update/draw cycle before the transition occurs, preventing potential issues that could arise from changing scenes in the middle of processing. The actual transition happens at a controlled point in the game loop, ensuring clean disposal of the old scene before initializing the new one. + +## Updating the Game + +With the scene architecture in place, the game can now be updated so that it is broken down into scenes. We will create two scenes; a title scene and a gameplay scene. First, however, we need to add an additional SpriteFont Description that will be used during the title scene to display the title of the game. Open the *Content.mgcb* content project file in the MGCB Editor and perform the following: + +1. Right-click the `fonts` folder and choose `Add > New Item...`. +2. Select `SpriteFont Description (.spritefont)` from the options. +3. Name the file `04B_30_5x` and click `Create`. + +| ![Figure 17-1: The *04B_30_5x.spritefont* file created in the MGCB Editor](./images/font_added.png) | +| :-------------------------------------------------------------------------------------------------: | +| **Figure 17-1: The *04B_30_5x.spritefont* file created in the MGCB Editor** | + +Next, open the *04B_30_5x.spritefont* file in your code editor and make the following changes: + +[!code-xml[](./snippets/04B_30_5x.spritefont?highlight=4-5)] + +### The Title Scene + +The title scene serves as the game's initial starting point; the first impression the player gets when they launch the game. For our game, the title scene will display the text for the title of the game and a prompt to inform the player what action to take to start the game. We will use a simple trick for the title text in order to draw it with a drop shadow to add a bit of visual flair. + +> [!NOTE] +> As the following screens are specific to our game and are not reusable bits, these will be added to your game project. +> +> Although, if you do end up making screens that are completely reusable, there is nothing wrong with putting them in your Game Library, it is completely up to you. + +To get started, first: + +1. In your Game project, create a new folder named `Scenes`. We will put all of our game specific scenes here. +2. Add a new class file named `TitleScene.cs` to the `Scenes` folder you just created. +3. Add the following code as the initial structure for the class. + + [!code-csharp[](./snippets/titlescene.cs#declaration)] + +#### Title Scene Fields + +Add the following fields to the `TitleScene` class: + +[!code-csharp[](./snippets/titlescene.cs#fields)] + +- Three `const` fields (`DUNGEON_TEXT`, `SLIME_TEXT`, `PRESS_ENTER_TEXT`) are added for the text that will be displayed on the title screen. +- The `_font` field stores a reference to the [**SpriteFont**](xref:Microsoft.Xna.Framework.Graphics.SpriteFont) we will use to draw the press enter prompt with. +- The `_font3x` field stores a reference to the [**SpriteFont**](xref:Microsoft.Xna.Framework.Graphics.SpriteFont) we will use to draw the dungeon and slime text with that will make up the title of the game. +- The `_dungeonTextPos` and `_dungeonTextOrigin` fields store the position and origin we will use to draw the "Dungeon" text at. +- The `_slimeTextPos` and `_slimeTextOrigin` fields store the position and origin we will draw the "Slime" text at. +- The `_pressEnterPos` and `_pressEnterOrigin` fields store the position and origin we will draw the "Press Enter To Start" text at. + +#### Title Scene Methods + +The `TitleScene` class will override the various methods from the base `Scene` class that it derives from to provide the initialization, content loading, update, and drawing logic. + +##### Title Scene Initialize + +Add the following override for the `Initialize` method to the `TitleScene` class: + +[!code-csharp[](./snippets/titlescene.cs#initialize)] + +- We set the `Core.ExitOnEscape` to true to allow players to exit the game when on the title screen by pressing the escape key. +- The position and origin for the "Dungeon", "Slime", and "Press Enter To Start" texts are set. + +> [!NOTE] +> You can see here we are using the [**MeasureString**](xref:Microsoft.Xna.Framework.Graphics.SpriteFont.MeasureString(System.String)) method for the font to work out how long the text to draw is, we then multiply this by `0.5` to work out the middle of the text so that we can properly set the origin of the text to its middle. + +##### Title Scene LoadContent + +Add the following override for the `LoadContent` method to the `TitleScene` class: + +[!code-csharp[](./snippets/titlescene.cs#loadcontent)] + +- The [**SpriteFont**](xref:Microsoft.Xna.Framework.Graphics.SpriteFont) used to draw the "Press Enter To Start" text is loaded using the global content manager. +- The [**SpriteFont**](xref:Microsoft.Xna.Framework.Graphics.SpriteFont) used to draw the "Dungeon" and "Slime" text is loaded using the scene's content manager. + +> [!TIP] +> Recall from [Chapter 05](../05_content_pipeline/index.md#contentmanager-methods) that when a [**ContentManager**](xref:Microsoft.Xna.Framework.Content.ContentManager) loads an asset for the first time, it caches it internally and the subsequent calls to load that asset will return the cached one instead of performing another disk read. +> +> By using a global content manager here to load assets that are used in multiple scenes, when they loaded in a different scene later, the cached version is returned instead of having to do another disk read, making the content loading more efficient. + +##### Title Scene Update + +Add the following override for the `Update` method to the `TitleScene` class: + +[!code-csharp[](./snippets/titlescene.cs#update)] + +- A check is made to see if the enter key is pressed, and if so, the `Core` is told to change to the game scene. + +> [!NOTE] +> Your editor might show an error here since we have not created the `GameScene` class yet. We will create it in a moment after finishing the title scene. + +> [!TIP] +> You will also notice the Title screen is only checking if the player hits the enter key to start the game, but we are not checking if they hit escape to quit the game, that is because it is already handled in the `Core` class `Update` method, if `ExitOnEscape` is true (as it is here), the game will automatically exit. + +##### Title Scene Draw + +For the final act, add the following override for the `Draw` method to the `TitleScene` class: + +[!code-csharp[](./snippets/titlescene.cs#draw)] + +- The back buffer is cleared. +- A `dropShadowColor` is created which is the color black with half transparency. +- The "Dungeon" text is drawn, first 10px down and to the left of the actual position using the drop shadow color, then again at its normal position overtop. Layering this way creates the drop shadow effect. +- The "Slime" text is drawn, again offset from its position first using the drop shadow color and then drawn again at its normal position overtop. +- Finally, the "Press Enter To Start" text is drawn. + +With our Title screen in place, it is time to get started with the Game Scene, lets play. + +### The Game Scene + +The Game Scene will contain our actual gameplay logic. This scene will handle updating and rendering the slime that the player controls, the bat the slime can eat, collision detection, score tracking, and input handling. Most of this logic has already been implemented in our `Game1` class in previous chapters, but now we will move it into a dedicated scene class. In the *Scenes* folder: + +1. Add a new class file named `GameScene.cs` in the Game projects `Scenes` folder. +2. Add the following code as the initial structure for the class: + + [!code-csharp[](./snippets/gamescene.cs#declaration)] + +The following code is effectively replacing the code we have already written in the original `Game1.cs` class, so it should look very familiar. Once complete, we can return to `Game1` and clear out all the redundant code because it is all nicely tidied up in the new `GameScene` class. + +#### Game Scene Fields + +Add the following fields to the `GameScene` class: + +[!code-csharp[](./snippets/gamescene.cs#fields)] + +- The `_slime` and `_bat` fields store the animated sprites for the player controlled slime and the bat. +- The `_slimePosition` and `_batPosition` fields track the current position of the slime and bat. +- The `MOVEMENT_SPEED` constant defines the base movement speed for both the slime and bat. +- The `_batVelocity` field tracks the current velocity of the bat as it moves around the screen. +- The `_tilemap` field stores the tilemap that we will load and draw for the level background environment. +- The `_roomBounds` field defines a rectangular boundary that represents the boundary of the room that the slime and bat stays within. +- The `_bounceSoundEffect` and `_collectSoundEffect` fields store the sound effects to play when the bat bounces off a screen edge or is eaten by the slime. +- The `_font` field stores the font used to display the player's score. +- The `_score` field tracks the player's current score, which increases when the slime eats a bat. +- The `_scoreTextPosition` and `_scoreTextOrigin` defines the position and origin to use when drawing the score text. + +#### Game Scene Methods + +The `GameScene` class will override the various methods from the base `Scene` class that it derives from to provide the initialization, content loading, update, and drawing logic. + +##### Game Scene Initialize + +Add the following override for the `Initialize` method to the `GameScene` class: + +[!code-csharp[](./snippets/gamescene.cs#initialize)] + +- We set `Core.ExitOnEscape` to false because in the gameplay scene, we want to handle the escape key differently; instead of exiting the game, it will return to the title screen. +- The room bounds is calculated using the bounds of the screen and adjusting that so that it shrinks by one tile width and height on each edge, which will match with the tilemap wall boundary. +- The slime's initial position is set to be the center tile by calculating the center row and column. +- The bat's initial position is placed at the top left of the room bounds. +- The position and origin of the score text is precalculated. The height of the text is measured to properly calculate the center origin for vertical positioning. +- The `AssignRandomBatVelocity` method is called to give the bat its initial velocity. + +##### Game Scene LoadContent + +Add the following override for the `LoadContent` method to the `GameScene` class: + +[!code-csharp[](./snippets/gamescene.cs#loadcontent)] + +- The texture atlas is loaded using the global content manager, and the slime and bat animated sprites are created from it. +- The tilemap is loaded using the scene's content manager since they are specific to the gameplay scene. +- The sound effects are loaded using the scene's content manager since they are specific to the gameplay scene. +- The font is loaded using the global content manager since it is used in multiple scenes. + +> [!TIP] +> Notice how we are following a consistent pattern across scenes: global assets are loaded with `Core.Instance.Content` while scene-specific assets are loaded with the scene's `Content` property. + +##### Game Scene Update + +Add the following override for the `Update` method to the `GameScene` class: + +[!code-csharp[](./snippets/gamescene.cs#update)] + +- The animated sprites for the slime and bat are updated. +- Input from keyboard and gamepad is checked with dedicated methods `CheckKeyboardInput` and `CheckGamePadInput`. +- Collision detection is performed to: + - Keep the slime within the room bounds. + - Make the bat bounce off edges of the room bounds. + - Detect when the slime eats the bat. +- When the slime eats the bat, the bat respawns in a random location within the room bounds, given a new velocity, the collect sound is played, and the score is increased. + +##### Game Scene Helper Methods + +Next, add these helper methods to the `GameScene` class: + +[!code-csharp[](./snippets/gamescene.cs#helpers)] + +- `AssignRandomBatVelocity`: Calculates a random direction and applies it to the bat's velocity. +- `CheckKeyboardInput`: Handles keyboard controls for moving the slime, toggling audio settings, and returning to the title screen. +- `CheckGamePadInput`: Handles gamepad controls for moving the slime. + +##### Game Scene Draw + +Finally, add the following override for the `Draw` method to the `GameScene` class: + +[!code-csharp[](./snippets/gamescene.cs#draw)] + +- The back buffer is cleared. +- The tilemap is drawn. +- The slime and bat animated sprites are drawn at their current positions. +- The player's score is drawn at using its precalculated position and origin so that it is in the top left of the room bounds centered on the wall sprite. + +This concludes the `GameScene` class. With all the logic for our actual gameplay now housed in a single place, we can clean up our project to use it. + +### Updating the Game1 Class + +With our scene system and scene classes in place, we can now simplify our main `Game1` class to just initialize the game and start with the title scene. Open the `Game1.cs` file and replace its content with the following: + +[!code-csharp[](./snippets/game1.cs)] + +> [!NOTE] +> Feel free to check your homework and compare the original `Game1` class with the updated version, as well as checking the `GameScene` class did not lose any functionality (it has not, but you have to be sure!). Refactoring code to be cleaner and more organised is a careful task. + +The `Game1` class is now much simpler as most of the game logic has been moved to the appropriate scene classes. + +The updates include: + +1. Sets up the game window with the constructor parameters. +2. Overrides the `Initialize` method to set the title scene as the starting scene. +3. Overrides the `LoadContent` method to load the background theme song and start playing it. + +Running the game now, we can see that once the game screen comes up, the title scene is displayed with the animated slime and the press enter prompt. The background music starts playing on this scene as well. Pressing enter from here will switch to the game scene where the game starts and we can play the game implemented thus far. + +| ![Figure 17-2: The game launching with the title screen first, then transitioning to the game play screen when enter is pressed](./videos/gameplay.webm) | +| :------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 17-2: The game launching with the title screen first, then transitioning to the game play screen when enter is pressed** | + +## Conclusion + +In this chapter, you accomplished the following: + +- Learned about scene management and why it is important for organizing game code. +- Created an abstract `Scene` base class that provides a consistent lifecycle similar to the MonoGame [**Game**](xref:Microsoft.Xna.Framework.Game) class. +- Implemented the `IDisposable` interface to properly handle resource cleanup. +- Extended the `Core` class to handle scene transitions and management. +- Created a `TitleScene` for the main menu with text prompts and animations. +- Created a `GameScene` that encapsulates the gameplay mechanics. +- Refactored the main `Game1` class to be much simpler by using the scene system. + +The approach we have taken follows a common pattern in game development, where each scene has control over its own lifecycle and resources. This pattern simplify state management by isolating different game states from one another. As your game grows in complexity, you could easily extend this system to include additional scenes like a pause menu or a game over screen. + +In the next chapter, we will explore [**RenderTarget2D**](xref:Microsoft.Xna.Framework.Graphics.RenderTarget2D) and how we can use it to add different types of transitions when switching scenes. + +## Test Your Knowledge + +1. What are the main benefits of implementing a scene management system in a game? + + :::question-answer + The main benefits include: + + - Improved organization by separating different parts of the game into self-contained modules. + - Better memory management by loading assets only when needed and unloading them when leaving a scene. + - Simplified state handling as each scene maintains its own state without affecting others. + - Increased code reusability through the ability to create reusable scene templates. + ::: + +2. How does the scene lifecycle in our implementation mirror the MonoGame Game class lifecycle? + + :::question-answer + The scene lifecycle mirrors the MonoGame Game class lifecycle by implementing similar methods in the same order: + + - `Initialize` is called once when the scene becomes active. + - `LoadContent` is called at the end of the `Initialize` method. + - `Update` is called every frame to update game logic. + - `Draw` is called every frame to render the scene. + - `UnloadContent` is called when transitioning away from the scene. + ::: + +3. What is the purpose of having a separate [**ContentManager**](xref:Microsoft.Xna.Framework.Content.ContentManager) for each scene? + + :::question-answer + Having a separate [**ContentManager**](xref:Microsoft.Xna.Framework.Content.ContentManager) for each scene: + + - Allows scene-specific content to be automatically unloaded when the scene is disposed. + - Provides better organization of which assets belong to which scenes. + - Improves memory efficiency by only loading assets that are currently needed. + - Makes it clear which assets are meant to be used globally versus locally to a scene. + ::: + +4. When implementing scene transitions, why do we use a two-step process with `_nextScene` and `_activeScene`? + + :::question-answer + The two-step process with `_nextScene` and `_activeScene` is used because: + + - It allows the current scene to complete its update/draw cycle before the transition occurs. + - It provides a clean way to handle the disposal of the current scene before initializing the new one. + - It ensures that scene transitions happen at a safe point in the game loop. + - It prevents potential issues that could occur from immediately changing scenes in the middle of an update or draw operation. + ::: diff --git a/articles/tutorials/building_2d_games/17_scenes/snippets/04B_30_5x.spritefont b/articles/tutorials/building_2d_games/17_scenes/snippets/04B_30_5x.spritefont new file mode 100644 index 00000000..dd239a53 --- /dev/null +++ b/articles/tutorials/building_2d_games/17_scenes/snippets/04B_30_5x.spritefont @@ -0,0 +1,16 @@ + + + + 04B_30.ttf + 87.5 + 0 + true + + + + + ~ + + + + diff --git a/articles/tutorials/building_2d_games/17_scenes/snippets/core.cs b/articles/tutorials/building_2d_games/17_scenes/snippets/core.cs new file mode 100644 index 00000000..a9eda94e --- /dev/null +++ b/articles/tutorials/building_2d_games/17_scenes/snippets/core.cs @@ -0,0 +1,206 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary.Audio; +using MonoGameLibrary.Input; +using MonoGameLibrary.Scenes; + +namespace MonoGameLibrary; + +public class Core : Game +{ + internal static Core s_instance; + + /// + /// Gets a reference to the Core instance. + /// + public static Core Instance => s_instance; + + // The scene that is currently active. + private static Scene s_activeScene; + + // The next scene to switch to, if there is one. + private static Scene s_nextScene; + + /// + /// Gets the graphics device manager to control the presentation of graphics. + /// + public static GraphicsDeviceManager Graphics { get; private set; } + + /// + /// Gets the graphics device used to create graphical resources and perform primitive rendering. + /// + public static new GraphicsDevice GraphicsDevice { get; private set; } + + /// + /// Gets the sprite batch used for all 2D rendering. + /// + public static SpriteBatch SpriteBatch { get; private set; } + + /// + /// Gets the content manager used to load global assets. + /// + public static new ContentManager Content { get; private set; } + + /// + /// Gets a reference to to the input management system. + /// + public static InputManager Input { get; private set; } + + /// + /// Gets or Sets a value that indicates if the game should exit when the esc key on the keyboard is pressed. + /// + public static bool ExitOnEscape { get; set; } + + /// + /// Gets a reference to the audio control system. + /// + public static AudioController Audio { get; private set; } + + /// + /// Creates a new Core instance. + /// + /// The title to display in the title bar of the game window. + /// The initial width, in pixels, of the game window. + /// The initial height, in pixels, of the game window. + /// Indicates if the game should start in fullscreen mode. + public Core(string title, int width, int height, bool fullScreen) + { + // Ensure that multiple cores are not created. + if (s_instance != null) + { + throw new InvalidOperationException($"Only a single Core instance can be created"); + } + + // Store reference to engine for global member access. + s_instance = this; + + // Create a new graphics device manager. + Graphics = new GraphicsDeviceManager(this); + + // Set the graphics defaults + Graphics.PreferredBackBufferWidth = width; + Graphics.PreferredBackBufferHeight = height; + Graphics.IsFullScreen = fullScreen; + + // Apply the graphic presentation changes + Graphics.ApplyChanges(); + + // Set the window title + Window.Title = title; + + // Set the core's content manager to a reference of hte base Game's + // content manager. + Content = base.Content; + + // Set the root directory for content + Content.RootDirectory = "Content"; + + // Mouse is visible by default + IsMouseVisible = true; + } + + protected override void Initialize() + { + base.Initialize(); + + // Set the core's graphics device to a reference of the base Game's + // graphics device. + GraphicsDevice = base.GraphicsDevice; + + // Create the sprite batch instance. + SpriteBatch = new SpriteBatch(GraphicsDevice); + + // Create a new input manager + Input = new InputManager(); + + // Create a new audio controller. + Audio = new AudioController(); + } + + protected override void UnloadContent() + { + // Dispose of the audio controller. + Audio.Dispose(); + + base.UnloadContent(); + } + + protected override void Update(GameTime gameTime) + { + // Update the input manager. + Input.Update(gameTime); + + // Update the audio controller. + Audio.Update(); + + if (ExitOnEscape && Input.Keyboard.WasKeyJustPressed(Keys.Escape)) + { + Exit(); + } + + // if there is a next scene waiting to be switch to, then transition + // to that scene + if (s_nextScene != null) + { + TransitionScene(); + } + + // If there is an active scene, update it. + if (s_activeScene != null) + { + s_activeScene.Update(gameTime); + } + + base.Update(gameTime); + } + + protected override void Draw(GameTime gameTime) + { + // If there is an active scene, draw it. + if (s_activeScene != null) + { + s_activeScene.Draw(gameTime); + } + + base.Draw(gameTime); + } + + public static void ChangeScene(Scene next) + { + // Only set the next scene value if it is not the same + // instance as the currently active scene. + if (s_activeScene != next) + { + s_nextScene = next; + } + } + + private static void TransitionScene() + { + // If there is an active scene, dispose of it + if (s_activeScene != null) + { + s_activeScene.Dispose(); + } + + // Force the garbage collector to collect to ensure memory is cleared + GC.Collect(); + + // Change the currently active scene to the new scene + s_activeScene = s_nextScene; + + // Null out the next scene value so it does not trigger a change over and over. + s_nextScene = null; + + // If the active scene now is not null, initialize it. + // Remember, just like with Game, the Initialize call also calls the + // Scene.LoadContent + if (s_activeScene != null) + { + s_activeScene.Initialize(); + } + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/17_scenes/snippets/game1.cs b/articles/tutorials/building_2d_games/17_scenes/snippets/game1.cs new file mode 100644 index 00000000..2b8fffb4 --- /dev/null +++ b/articles/tutorials/building_2d_games/17_scenes/snippets/game1.cs @@ -0,0 +1,33 @@ +using DungeonSlime.Scenes; +using Microsoft.Xna.Framework.Media; +using MonoGameLibrary; + +namespace DungeonSlime; + +public class Game1 : Core +{ + // The background theme song + private Song _themeSong; + + public Game1() : base("Dungeon Slime", 1280, 720, false) + { + + } + + protected override void Initialize() + { + base.Initialize(); + + // Start playing the background music + Audio.PlaySong(_themeSong); + + // Start the game with the title scene. + ChangeScene(new TitleScene()); + } + + protected override void LoadContent() + { + // Load the background theme music + _themeSong = Content.Load("audio/theme"); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/17_scenes/snippets/gamescene.cs b/articles/tutorials/building_2d_games/17_scenes/snippets/gamescene.cs new file mode 100644 index 00000000..930a4c05 --- /dev/null +++ b/articles/tutorials/building_2d_games/17_scenes/snippets/gamescene.cs @@ -0,0 +1,416 @@ +#region declaration +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; +using MonoGameLibrary.Input; +using MonoGameLibrary.Scenes; + +namespace DungeonSlime.Scenes; + +public class GameScene : Scene +{ + +} +#endregion +{ + #region fields + // Defines the slime animated sprite. + private AnimatedSprite _slime; + + // Defines the bat animated sprite. + private AnimatedSprite _bat; + + // Tracks the position of the slime. + private Vector2 _slimePosition; + + // Speed multiplier when moving. + private const float MOVEMENT_SPEED = 5.0f; + + // Tracks the position of the bat. + private Vector2 _batPosition; + + // Tracks the velocity of the bat. + private Vector2 _batVelocity; + + // Defines the tilemap to draw. + private Tilemap _tilemap; + + // Defines the bounds of the room that the slime and bat are contained within. + private Rectangle _roomBounds; + + // The sound effect to play when the bat bounces off the edge of the screen. + private SoundEffect _bounceSoundEffect; + + // The sound effect to play when the slime eats a bat. + private SoundEffect _collectSoundEffect; + + // The SpriteFont Description used to draw text + private SpriteFont _font; + + // Tracks the players score. + private int _score; + + // Defines the position to draw the score text at. + private Vector2 _scoreTextPosition; + + // Defines the origin used when drawing the score text. + private Vector2 _scoreTextOrigin; + #endregion + + #region initialize + public override void Initialize() + { + // LoadContent is called during base.Initialize(). + base.Initialize(); + + // During the game scene, we want to disable exit on escape. Instead, + // the escape key will be used to return back to the title screen + Core.ExitOnEscape = false; + + Rectangle screenBounds = Core.GraphicsDevice.PresentationParameters.Bounds; + + _roomBounds = new Rectangle( + (int)_tilemap.TileWidth, + (int)_tilemap.TileHeight, + screenBounds.Width - (int)_tilemap.TileWidth * 2, + screenBounds.Height - (int)_tilemap.TileHeight * 2 + ); + + // Initial slime position will be the center tile of the tile map. + int centerRow = _tilemap.Rows / 2; + int centerColumn = _tilemap.Columns / 2; + _slimePosition = new Vector2(centerColumn * _tilemap.TileWidth, centerRow * _tilemap.TileHeight); + + // Initial bat position will the in the top left corner of the room + _batPosition = new Vector2(_roomBounds.Left, _roomBounds.Top); + + // Set the position of the score text to align to the left edge of the + // room bounds, and to vertically be at the center of the first tile. + _scoreTextPosition = new Vector2(_roomBounds.Left, _tilemap.TileHeight * 0.5f); + + // Set the origin of the text so it is left-centered. + float scoreTextYOrigin = _font.MeasureString("Score").Y * 0.5f; + _scoreTextOrigin = new Vector2(0, scoreTextYOrigin); + + // Assign the initial random velocity to the bat. + AssignRandomBatVelocity(); + } + #endregion + + #region loadcontent + public override void LoadContent() + { + // Create the texture atlas from the XML configuration file + TextureAtlas atlas = TextureAtlas.FromFile(Core.Content, "images/atlas-definition.xml"); + + // Create the slime animated sprite from the atlas. + _slime = atlas.CreateAnimatedSprite("slime-animation"); + _slime.Scale = new Vector2(4.0f, 4.0f); + + // Create the bat animated sprite from the atlas. + _bat = atlas.CreateAnimatedSprite("bat-animation"); + _bat.Scale = new Vector2(4.0f, 4.0f); + + // Create the tilemap from the XML configuration file. + _tilemap = Tilemap.FromFile(Content, "images/tilemap-definition.xml"); + _tilemap.Scale = new Vector2(4.0f, 4.0f); + + // Load the bounce sound effect + _bounceSoundEffect = Content.Load("audio/bounce"); + + // Load the collect sound effect + _collectSoundEffect = Content.Load("audio/collect"); + + // Load the font + _font = Core.Content.Load("fonts/04B_30"); + } + #endregion + + #region update + public override void Update(GameTime gameTime) + { + // Update the slime animated sprite. + _slime.Update(gameTime); + + // Update the bat animated sprite. + _bat.Update(gameTime); + + // Check for keyboard input and handle it. + CheckKeyboardInput(); + + // Check for gamepad input and handle it. + CheckGamePadInput(); + + // Creating a bounding circle for the slime + Circle slimeBounds = new Circle( + (int)(_slimePosition.X + (_slime.Width * 0.5f)), + (int)(_slimePosition.Y + (_slime.Height * 0.5f)), + (int)(_slime.Width * 0.5f) + ); + + // Use distance based checks to determine if the slime is within the + // bounds of the game screen, and if it is outside that screen edge, + // move it back inside. + if (slimeBounds.Left < _roomBounds.Left) + { + _slimePosition.X = _roomBounds.Left; + } + else if (slimeBounds.Right > _roomBounds.Right) + { + _slimePosition.X = _roomBounds.Right - _slime.Width; + } + + if (slimeBounds.Top < _roomBounds.Top) + { + _slimePosition.Y = _roomBounds.Top; + } + else if (slimeBounds.Bottom > _roomBounds.Bottom) + { + _slimePosition.Y = _roomBounds.Bottom - _slime.Height; + } + + // Calculate the new position of the bat based on the velocity + Vector2 newBatPosition = _batPosition + _batVelocity; + + // Create a bounding circle for the bat + Circle batBounds = new Circle( + (int)(newBatPosition.X + (_bat.Width * 0.5f)), + (int)(newBatPosition.Y + (_bat.Height * 0.5f)), + (int)(_bat.Width * 0.5f) + ); + + Vector2 normal = Vector2.Zero; + + // Use distance based checks to determine if the bat is within the + // bounds of the game screen, and if it is outside that screen edge, + // reflect it about the screen edge normal + if (batBounds.Left < _roomBounds.Left) + { + normal.X = Vector2.UnitX.X; + newBatPosition.X = _roomBounds.Left; + } + else if (batBounds.Right > _roomBounds.Right) + { + normal.X = -Vector2.UnitX.X; + newBatPosition.X = _roomBounds.Right - _bat.Width; + } + + if (batBounds.Top < _roomBounds.Top) + { + normal.Y = Vector2.UnitY.Y; + newBatPosition.Y = _roomBounds.Top; + } + else if (batBounds.Bottom > _roomBounds.Bottom) + { + normal.Y = -Vector2.UnitY.Y; + newBatPosition.Y = _roomBounds.Bottom - _bat.Height; + } + + // If the normal is anything but Vector2.Zero, this means the bat had + // moved outside the screen edge so we should reflect it about the + // normal. + if (normal != Vector2.Zero) + { + _batVelocity = Vector2.Reflect(_batVelocity, normal); + + // Play the bounce sound effect + Core.Audio.PlaySoundEffect(_bounceSoundEffect); + } + + _batPosition = newBatPosition; + + if (slimeBounds.Intersects(batBounds)) + { + // Choose a random row and column based on the total number of each + int column = Random.Shared.Next(1, _tilemap.Columns - 1); + int row = Random.Shared.Next(1, _tilemap.Rows - 1); + + // Change the bat position by setting the x and y values equal to + // the column and row multiplied by the width and height. + _batPosition = new Vector2(column * _bat.Width, row * _bat.Height); + + // Assign a new random velocity to the bat + AssignRandomBatVelocity(); + + // Play the collect sound effect + Core.Audio.PlaySoundEffect(_collectSoundEffect); + + // Increase the player's score. + _score += 100; + } + } + #endregion + + #region helpers + private void AssignRandomBatVelocity() + { + // Generate a random angle + float angle = (float)(Random.Shared.NextDouble() * Math.PI * 2); + + // Convert angle to a direction vector + float x = (float)Math.Cos(angle); + float y = (float)Math.Sin(angle); + Vector2 direction = new Vector2(x, y); + + // Multiply the direction vector by the movement speed + _batVelocity = direction * MOVEMENT_SPEED; + } + + private void CheckKeyboardInput() + { + // Get a reference to the keyboard inof + KeyboardInfo keyboard = Core.Input.Keyboard; + + // If the escape key is pressed, return to the title screen + if (Core.Input.Keyboard.WasKeyJustPressed(Keys.Escape)) + { + Core.ChangeScene(new TitleScene()); + } + + // If the space key is held down, the movement speed increases by 1.5 + float speed = MOVEMENT_SPEED; + if (keyboard.IsKeyDown(Keys.Space)) + { + speed *= 1.5f; + } + + // If the W or Up keys are down, move the slime up on the screen. + if (keyboard.IsKeyDown(Keys.W) || keyboard.IsKeyDown(Keys.Up)) + { + _slimePosition.Y -= speed; + } + + // if the S or Down keys are down, move the slime down on the screen. + if (keyboard.IsKeyDown(Keys.S) || keyboard.IsKeyDown(Keys.Down)) + { + _slimePosition.Y += speed; + } + + // If the A or Left keys are down, move the slime left on the screen. + if (keyboard.IsKeyDown(Keys.A) || keyboard.IsKeyDown(Keys.Left)) + { + _slimePosition.X -= speed; + } + + // If the D or Right keys are down, move the slime right on the screen. + if (keyboard.IsKeyDown(Keys.D) || keyboard.IsKeyDown(Keys.Right)) + { + _slimePosition.X += speed; + } + + // If the M key is pressed, toggle mute state for audio. + if (keyboard.WasKeyJustPressed(Keys.M)) + { + Core.Audio.ToggleMute(); + } + + // If the + button is pressed, increase the volume. + if (keyboard.WasKeyJustPressed(Keys.OemPlus)) + { + Core.Audio.SongVolume += 0.1f; + Core.Audio.SoundEffectVolume += 0.1f; + } + + // If the - button was pressed, decrease the volume. + if (keyboard.WasKeyJustPressed(Keys.OemMinus)) + { + Core.Audio.SongVolume -= 0.1f; + Core.Audio.SoundEffectVolume -= 0.1f; + } + } + + private void CheckGamePadInput() + { + // Get the gamepad info for gamepad one. + GamePadInfo gamePadOne = Core.Input.GamePads[(int)PlayerIndex.One]; + + // If the A button is held down, the movement speed increases by 1.5 + // and the gamepad vibrates as feedback to the player. + float speed = MOVEMENT_SPEED; + if (gamePadOne.IsButtonDown(Buttons.A)) + { + speed *= 1.5f; + GamePad.SetVibration(PlayerIndex.One, 1.0f, 1.0f); + } + else + { + GamePad.SetVibration(PlayerIndex.One, 0.0f, 0.0f); + } + + // Check thumbstick first since it has priority over which gamepad input + // is movement. It has priority since the thumbstick values provide a + // more granular analog value that can be used for movement. + if (gamePadOne.LeftThumbStick != Vector2.Zero) + { + _slimePosition.X += gamePadOne.LeftThumbStick.X * speed; + _slimePosition.Y -= gamePadOne.LeftThumbStick.Y * speed; + } + else + { + // If DPadUp is down, move the slime up on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadUp)) + { + _slimePosition.Y -= speed; + } + + // If DPadDown is down, move the slime down on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadDown)) + { + _slimePosition.Y += speed; + } + + // If DPapLeft is down, move the slime left on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadLeft)) + { + _slimePosition.X -= speed; + } + + // If DPadRight is down, move the slime right on the screen. + if (gamePadOne.IsButtonDown(Buttons.DPadRight)) + { + _slimePosition.X += speed; + } + } + } + #endregion + + #region draw + public override void Draw(GameTime gameTime) + { + // Clear the back buffer. + Core.GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + Core.SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + // Draw the tilemap + _tilemap.Draw(Core.SpriteBatch); + + // Draw the slime sprite. + _slime.Draw(Core.SpriteBatch, _slimePosition); + + // Draw the bat sprite. + _bat.Draw(Core.SpriteBatch, _batPosition); + + // Draw the score + Core.SpriteBatch.DrawString( + _font, // spriteFont + $"Score: {_score}", // text + _scoreTextPosition, // position + Color.White, // color + 0.0f, // rotation + _scoreTextOrigin, // origin + 1.0f, // scale + SpriteEffects.None, // effects + 0.0f // layerDepth + ); + + // Always end the sprite batch when finished. + Core.SpriteBatch.End(); + } + #endregion +} diff --git a/articles/tutorials/building_2d_games/17_scenes/snippets/scene.cs b/articles/tutorials/building_2d_games/17_scenes/snippets/scene.cs new file mode 100644 index 00000000..b31698f0 --- /dev/null +++ b/articles/tutorials/building_2d_games/17_scenes/snippets/scene.cs @@ -0,0 +1,117 @@ +#region declaration +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; + +namespace MonoGameLibrary.Scenes; + +public abstract class Scene : IDisposable +{ + +} +#endregion +{ + #region properties + /// + /// Gets the ContentManager used for loading scene-specific assets. + /// + /// + /// Assets loaded through this ContentManager will be automatically unloaded when this scene ends. + /// + protected ContentManager Content { get; } + + /// + /// Gets a value that indicates if the scene has been disposed of. + /// + public bool IsDisposed { get; private set; } + #endregion + + #region ctors + /// + /// Creates a new scene instance. + /// + public Scene() + { + // Create a content manager for the scene + Content = new ContentManager(Core.Content.ServiceProvider); + + // Set the root directory for content to the same as the root directory + // for the game's content. + Content.RootDirectory = Core.Content.RootDirectory; + } + + // Finalizer, called when object is cleaned up by garbage collector. + ~Scene() => Dispose(false); + #endregion + + #region methods + /// + /// Initializes the scene. + /// + /// + /// When overriding this in a derived class, ensure that base.Initialize() + /// still called as this is when LoadContent is called. + /// + public virtual void Initialize() + { + LoadContent(); + } + + /// + /// Override to provide logic to load content for the scene. + /// + public virtual void LoadContent() { } + + /// + /// Unloads scene-specific content. + /// + public virtual void UnloadContent() + { + Content.Unload(); + } + + /// + /// Updates this scene. + /// + /// A snapshot of the timing values for the current frame. + public virtual void Update(GameTime gameTime) { } + + /// + /// Draws this scene. + /// + /// A snapshot of the timing values for the current frame. + public virtual void Draw(GameTime gameTime) { } + #endregion + + #region disposable + /// + /// Disposes of this scene. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes of this scene. + /// + /// ' + /// Indicates whether managed resources should be disposed. This value is only true when called from the main + /// Dispose method. When called from the finalizer, this will be false. + /// + protected virtual void Dispose(bool disposing) + { + if (IsDisposed) + { + return; + } + + if (disposing) + { + UnloadContent(); + Content.Dispose(); + } + } + #endregion +} diff --git a/articles/tutorials/building_2d_games/17_scenes/snippets/titlescene.cs b/articles/tutorials/building_2d_games/17_scenes/snippets/titlescene.cs new file mode 100644 index 00000000..68baea08 --- /dev/null +++ b/articles/tutorials/building_2d_games/17_scenes/snippets/titlescene.cs @@ -0,0 +1,127 @@ +#region declaration +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary; +using MonoGameLibrary.Scenes; + +namespace DungeonSlime.Scenes; + +public class TitleScene : Scene +{ + +} +#endregion +{ + #region fields + private const string DUNGEON_TEXT = "Dungeon"; + private const string SLIME_TEXT = "Slime"; + private const string PRESS_ENTER_TEXT = "Press Enter To Start"; + + // The font to use to render normal text. + private SpriteFont _font; + + // The font used to render the title text. + private SpriteFont _font5x; + + // The position to draw the dungeon text at. + private Vector2 _dungeonTextPos; + + // The origin to set for the dungeon text. + private Vector2 _dungeonTextOrigin; + + // The position to draw the slime text at. + private Vector2 _slimeTextPos; + + // The origin to set for the slime text. + private Vector2 _slimeTextOrigin; + + // The position to draw the press enter text at. + private Vector2 _pressEnterPos; + + // The origin to set for the press enter text when drawing it. + private Vector2 _pressEnterOrigin; + #endregion + + #region initialize + public override void Initialize() + { + // LoadContent is called during base.Initialize(). + base.Initialize(); + + // While on the title screen, we can enable exit on escape so the player + // can close the game by pressing the escape key. + Core.ExitOnEscape = true; + + // Set the position and origin for the Dungeon text. + Vector2 size = _font5x.MeasureString(DUNGEON_TEXT); + _dungeonTextPos = new Vector2(640, 100); + _dungeonTextOrigin = size * 0.5f; + + // Set the position and origin for the Slime text. + size = _font5x.MeasureString(SLIME_TEXT); + _slimeTextPos = new Vector2(757, 207); + _slimeTextOrigin = size * 0.5f; + + // Set the position and origin for the press enter text. + size = _font.MeasureString(PRESS_ENTER_TEXT); + _pressEnterPos = new Vector2(640, 620); + _pressEnterOrigin = size * 0.5f; + } + #endregion + + #region loadcontent + public override void LoadContent() + { + // Load the font for the standard text. + _font = Core.Content.Load("fonts/04B_30"); + + // Load the font for the title text + _font5x = Content.Load("fonts/04B_30_5x"); + } + #endregion + + #region update + public override void Update(GameTime gameTime) + { + // If the user presses enter, switch to the game scene. + if (Core.Input.Keyboard.WasKeyJustPressed(Keys.Enter)) + { + Core.ChangeScene(new GameScene()); + } + } + #endregion + + #region draw + public override void Draw(GameTime gameTime) + { + Core.GraphicsDevice.Clear(new Color(32, 40, 78, 255)); + + // Begin the sprite batch to prepare for rendering. + Core.SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + Color dropShadowColor = new Color(19, 23, 46, 175); + dropShadowColor = Color.Black * 0.5f; + + // Draw the Dungeon text slightly offset from it is original position and + // with a transparent color to give it a drop shadow + Core.SpriteBatch.DrawString(_font5x, DUNGEON_TEXT, _dungeonTextPos + new Vector2(10, 10), dropShadowColor, 0.0f, _dungeonTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + + // Draw the Dungeon text on top of that at its original position + Core.SpriteBatch.DrawString(_font5x, DUNGEON_TEXT, _dungeonTextPos, Color.White, 0.0f, _dungeonTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + + // Draw the Slime text slightly offset from it is original position and + // with a transparent color to give it a drop shadow + Core.SpriteBatch.DrawString(_font5x, SLIME_TEXT, _slimeTextPos + new Vector2(10, 10), dropShadowColor, 0.0f, _slimeTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + + // Draw the Slime text on top of that at its original position + Core.SpriteBatch.DrawString(_font5x, SLIME_TEXT, _slimeTextPos, Color.White, 0.0f, _slimeTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + + // Draw the press enter text + Core.SpriteBatch.DrawString(_font, PRESS_ENTER_TEXT, _pressEnterPos, Color.White, 0.0f, _pressEnterOrigin, 1.0f, SpriteEffects.None, 0.0f); + + // Always end the sprite batch when finished. + Core.SpriteBatch.End(); + } + #endregion +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/17_scenes/videos/gameplay.webm b/articles/tutorials/building_2d_games/17_scenes/videos/gameplay.webm new file mode 100644 index 00000000..1ffd8e37 Binary files /dev/null and b/articles/tutorials/building_2d_games/17_scenes/videos/gameplay.webm differ diff --git a/articles/tutorials/building_2d_games/18_texture_sampling/images/address-mode-border.png b/articles/tutorials/building_2d_games/18_texture_sampling/images/address-mode-border.png new file mode 100644 index 00000000..7a1dcc06 Binary files /dev/null and b/articles/tutorials/building_2d_games/18_texture_sampling/images/address-mode-border.png differ diff --git a/articles/tutorials/building_2d_games/18_texture_sampling/images/address-mode-clamped.png b/articles/tutorials/building_2d_games/18_texture_sampling/images/address-mode-clamped.png new file mode 100644 index 00000000..1553d4b5 Binary files /dev/null and b/articles/tutorials/building_2d_games/18_texture_sampling/images/address-mode-clamped.png differ diff --git a/articles/tutorials/building_2d_games/18_texture_sampling/images/address-mode-mirror.png b/articles/tutorials/building_2d_games/18_texture_sampling/images/address-mode-mirror.png new file mode 100644 index 00000000..d6a3ca98 Binary files /dev/null and b/articles/tutorials/building_2d_games/18_texture_sampling/images/address-mode-mirror.png differ diff --git a/articles/tutorials/building_2d_games/18_texture_sampling/images/address-mode-wrap.png b/articles/tutorials/building_2d_games/18_texture_sampling/images/address-mode-wrap.png new file mode 100644 index 00000000..eacb8aa3 Binary files /dev/null and b/articles/tutorials/building_2d_games/18_texture_sampling/images/address-mode-wrap.png differ diff --git a/articles/tutorials/building_2d_games/18_texture_sampling/images/background-pattern.png b/articles/tutorials/building_2d_games/18_texture_sampling/images/background-pattern.png new file mode 100644 index 00000000..2d8d878e Binary files /dev/null and b/articles/tutorials/building_2d_games/18_texture_sampling/images/background-pattern.png differ diff --git a/articles/tutorials/building_2d_games/18_texture_sampling/images/filter-mode-anisotropic-comparison.png b/articles/tutorials/building_2d_games/18_texture_sampling/images/filter-mode-anisotropic-comparison.png new file mode 100644 index 00000000..e87c3baa Binary files /dev/null and b/articles/tutorials/building_2d_games/18_texture_sampling/images/filter-mode-anisotropic-comparison.png differ diff --git a/articles/tutorials/building_2d_games/18_texture_sampling/images/filter-mode-anisotropic-linear-comparison.png b/articles/tutorials/building_2d_games/18_texture_sampling/images/filter-mode-anisotropic-linear-comparison.png new file mode 100644 index 00000000..80064877 Binary files /dev/null and b/articles/tutorials/building_2d_games/18_texture_sampling/images/filter-mode-anisotropic-linear-comparison.png differ diff --git a/articles/tutorials/building_2d_games/18_texture_sampling/images/filter-mode-linear.png b/articles/tutorials/building_2d_games/18_texture_sampling/images/filter-mode-linear.png new file mode 100644 index 00000000..6f9c0331 Binary files /dev/null and b/articles/tutorials/building_2d_games/18_texture_sampling/images/filter-mode-linear.png differ diff --git a/articles/tutorials/building_2d_games/18_texture_sampling/images/filter-mode-point.png b/articles/tutorials/building_2d_games/18_texture_sampling/images/filter-mode-point.png new file mode 100644 index 00000000..55073bbe Binary files /dev/null and b/articles/tutorials/building_2d_games/18_texture_sampling/images/filter-mode-point.png differ diff --git a/articles/tutorials/building_2d_games/18_texture_sampling/images/mgcb-editor.png b/articles/tutorials/building_2d_games/18_texture_sampling/images/mgcb-editor.png new file mode 100644 index 00000000..cb3bcef7 Binary files /dev/null and b/articles/tutorials/building_2d_games/18_texture_sampling/images/mgcb-editor.png differ diff --git a/articles/tutorials/building_2d_games/18_texture_sampling/index.md b/articles/tutorials/building_2d_games/18_texture_sampling/index.md new file mode 100644 index 00000000..1a7ee158 --- /dev/null +++ b/articles/tutorials/building_2d_games/18_texture_sampling/index.md @@ -0,0 +1,241 @@ +--- +title: "Chapter 18: Texture Sampling and Tiling Backgrounds" +description: "Learn how to use texture sampling states and add a scrolling background effect to the game." +--- + +In previous chapters, we have [drawn individual sprites and textures](../08_the_sprite_class/index.md#using-the-sprite-class) with the sprite batch, but for creating repeating background patterns, we need a more efficient approach than manually drawing the same texture multiple times. We could reuse the [tilemap system](../13_working_with_tilemaps/index.md#the-tilemap-class) that was created to make repeated background patterns, but this has a limitation in that the tiles are stationary and would require constantly updating the tiles and positions if we wanted to animate it. Instead, this chapter introduces texture sampling states, specifically focusing on how to create and animate tiled backgrounds using [**SamplerState.PointWrap**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.PointWrap). + +In this chapter, you will: + +- Learn about texture sampling and sampler states in MonoGame. +- Understand how wrap mode allows efficient texture tiling. +- Implement a scrolling tiled background for the title screen. + +## Understanding Texture Sampling + +When a texture is drawn to the screen, MonoGame uses a process called "sampling" to determine which pixels from the texture should be displayed. Sampling is the process by which a graphics pipeline determines what color value to use from a texture when mapping it onto a surface. Think of it like placing a grid over an image and selecting which pixels to use when that image needs to be transformed in some way. When textures are drawn at their exact pixel size and position, with no rotation, sampling is straightforward, a direct 1:1 mapping for each pixel. However, when a texture is scaled, rotated, or only partially visible, the graphics hardware needs to decide how to interpret the texture data. + +For example, if you draw a texture twice its size, there are not enough pixels to fill the new larger space, so the graphics hardware must determine how to fill those gaps. Similarly, if you were to scale down a texture, multiple source pixels might map to a single output pixel, requiring the hardware to decide which ones to use or how to blend them. The rules that govern these decisions are defined by sampler states. + +### Texture Coordinates + +In graphics programming, textures are addressed using a normalized coordinate system ranging from 0.0 to 1.0, regardless of the texture's actual pixel dimensions: + +- The top-left corner of a texture is (0.0, 0.0) +- The bottom-right corner is (1.0, 1.0) +- The center is (0.5, 0.5) + +This normalized system means that regardless of whether your texture is 32×32 pixels or 2048×2048 pixels, the coordinates to access the entire texture always range from 0.0 to 1.0. The graphics hardware automatically converts these normalized coordinates to the actual pixel locations within the texture. + +When you use [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch) to draw a texture with a source rectangle specified in pixels, MonoGame internally converts those pixel coordinates to normalized texture coordinates before sending them to the GPU. Similarly, when you specify a destination rectangle, MonoGame determines how the normalized texture coordinates should map to screen coordinates. + +### What is a SampleState + +A SamplerState controls how textures are sampled during rendering. It determines several aspects of texture rendering: + +- How textures are filtered when scaled (point/linear/anisotropic filtering). +- How texture coordinates outside the 0.0 to 1.0 range are handled (wrap/clamp/mirror). +- How mipmap levels are selected and blended. + +In MonoGame, these sampler states are represented by the [**SamplerState**](xref:Microsoft.Xna.Framework.Graphics.SamplerState) class, which provides several predefined states for common scenarios. + +### Filtering Modes + +One aspect of sampler states if the filtering mode. Filtering in computer graphics refers to how the graphics hardware decides to blend or select pixels when a texture is displayed at a different size than its original dimensions. The filter mode determines how pixels are interpolated (calculated and combined) when a texture is scaled up or down. + +Think of filtering as the graphics hardware's strategy for filling in missing information when a texture is transformed. When you enlarge a texture, the system needs to create new pixels that did not exist in the original. When you shrink a texture, multiple original pixels must be combined into fewer output pixels. The filtering mode controls how this process happens. + +There are three filtering modes available in MonoGame: + +* Point +* Linear +* Anisotropic. + +Each mode offers a different balance between performance and visual quality. + +#### Point Filtering Mode + +Point mode uses what is called nearest neighbor sampling. This means that when a texture is scaled, the closest pixel is selected resulting in a pixelated appearance when scaled up. This is typically the ideal mode to use for pixel-art games when you want to preserve the exact pixel appearance of a scaled texture. Point filtering is the least computationally expensive filtering mode of the three since it only samples a single pixel without any blending calculations. This makes it the fastest option, especially on lower-end hardware or when rendering many textures simultaneously. + +| ![Figure 18-1: Illustration of using Point filtering mode. Left: MonoGame logo at 32x32 pixels. Right: MonoGame logo at 128x128 pixels](./images/filter-mode-point.png) | +| :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 18-1: Illustration of using Point filtering mode. Left: MonoGame logo at 32x32 pixels. Right: MonoGame logo at 128x128 pixels** | + +#### Linear Filtering Mode + +Linear filtering mode blends neighboring pixels when the texture is scaled. This creates a smoother, but potentially blurrier appearance. This is better for realistic or high-resolution textures. Linear filtering requires more processing power than point filtering since it needs to sample multiple pixels and calculated weighted averages between them. However, on modern hardware, this performance difference is usually negligible for 2D games, making it a good balance between quality and performance for most non-pixel art games or assets. + +| ![Figure 18-2: Illustration of using Linear filtering mode. Left: MonoGame logo at 32x32 pixels. Right: MonoGame logo at 128x128 pixels](./images/filter-mode-linear.png) | +| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 18-2: Illustration of using Linear filtering mode. Left: MonoGame logo at 32x32 pixels. Right: MonoGame logo at 128x128 pixels** | + +#### Anisotropic Filtering Mode + +Anisotropic filtering mode provides higher-quality filter for textures viewed from oblique angles. This is primarily used in 3D rendering. It helps textures look more detailed by reducing blur and aliasing that occurs when a surface is angled away from the viewer. Anisotropic filtering is the most computationally intensive option, as it samples many more pixels and performs complex calculations to determine the appropriate blending. The performance cost increases with the anisotropic level (typically 2x, 4x, 8x, or 16x), which determines how many samples are taken. This can significantly impact frame rates in complex 3D scenes, especially on mobile or lower-end devices, so it should be used selectively where visual quality at angles is most important. + +| ![Figure 18-3: Illustration of the MonoGame Fuel cell demo using Linear filtering](./images/filter-mode-anisotropic-linear-comparison.png) | ![Figure 18-4: Illustration of the MonoGame Fuel cell demo using Anisotropic filtering](./images/filter-mode-anisotropic-comparison.png) | +| :----------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 18-3: Illustration of the MonoGame Fuel cell demo using Linear filtering** | **Figure 18-4: Illustration of the MonoGame Fuel cell demo using Anisotropic filtering** | + +### Addressing Modes + +The other aspect is the addressing mode, which determines what happens when texture coordinates fall outside the normal 0.0 to 1.0 range. When drawing textures, sometimes the calculated texture coordinates end up being less than 0.0 or greater than 1.0. The addressing mode tells the graphics hardware what to do in these situations; whether to repeat the texture, mirror it, stretch the edge pixels, or use a specific border color. Think of it as instructions for what to display in areas where the texture does not naturally exist. These modes are particularly important for creating effects like seamless tiling backgrounds, scrolling texture, or handling the edges of transformed sprites properly. There are four addressing modes available; Wrap, Mirror, Clamp, and Border Color: + +#### Wrap Mode + +When using Wrap mode, at every whole integer of the texture coordinates (0.0 and 1.0), the texture coordinate is wrapped to stay within the 0.0 to 1.0 range (i.e. if the texture coordinate is 1.2, then that wraps to become 0.2). This creates a tiled pattern. + +For example, if we were to take the MonoGame logo at 128x128 pixels and draw it to a destination rectangle that was three times the size at 384x384 pixels, then the texture coordinates of the destination rectangle become (0.0, 0.0) (top-left), (3.0, 0.0) (top-right), (0.0) (bottom-left), and (3.0, 3.0) bottom-right. The MonoGame logo texture would repeat three times horizontally and vertically within the destination. + +| ![Figure 18-5: Illustration of the MonoGame logo drawn using wrapped addressing mode](./images/address-mode-wrap.png) | +| :-------------------------------------------------------------------------------------------------------------------: | +| **Figure 18-5: Illustration of the MonoGame logo drawn using wrapped addressing mode** | + +#### Mirror Mode + +Mirror mode is similar to Wrap mode. However instead of repeating the texture at every whole integer of the texture coordinates (0.0 and 1.0), the texture is flipped, creating a mirror effect. + +Using the same example as above, taking the MonoGame logo at 128x128 pixels and drawing it to a destination rectangle three times the size with Mirror mode would produce the following: + +| ![Figure 18-6: Illustration of the MonoGame logo drawn using mirror addressing mode](./images/address-mode-mirror.png) | +| :--------------------------------------------------------------------------------------------------------------------: | +| **Figure 18-6: Illustration of the MonoGame logo drawn using mirror addressing mode** | + +#### Clamp Mode + +When using Clamp mode, the texture coordinates are clamped to the 0.0 and 1.0 range. Texture coordinates that would go beyond this (edge pixels) are smeared. + +The simplest demonstration of this is to use a checkerboard pattern. If we were to take a texture that was a checkerboard pattern at 128x128 pixels and draw it to a destination rectangle three times the size with Clamped mode, then any pixels that extend outside the clamped range would smeared, producing the following: + +| ![Figure 18-7: Illustration of a checkerboard pattern drawn using clamped addressing mode](./images/address-mode-clamped.png) | +| :---------------------------------------------------------------------------------------------------------------------------: | +| **Figure 18-7: Illustration of a checkerboard pattern drawn using clamped addressing mode** | + +#### Border Color + +When using Border Color mode, similar to Clamped mode, the texture coordinates are clamped to the 0.0 and 1.0f range. However, in Border Color mode, texture coordinates that would go beyond this (edge pixels) are instead drawn using the color set as the border color for the sampler state. + +For example, if we use the checkerboard pattern again, using Border Color mode with a border color of green, then it would produce the following: + +| ![Figure 18-8: Illustration of a checkerboard pattern drawn using border addressing mode with the border color set to green](./images/address-mode-border.png) | +| :------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 18-8: Illustration of a checkerboard pattern drawn using border addressing mode with the border color set to green** | + +## Using SamplerStates + +MonoGame offers several predefined sampler states as part of the [**SamplerState**](xref:Microsoft.Xna.Framework.Graphics.SamplerState) class that cover common scenarios: + +| SamplerState | Description | Common Use Case | +| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| [**AnisotropicClamp**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.AnisotropicClamp) | Combines Anisotropic filter mode with the Clamp addressing mode. | 3D textures viewed at oblique angles, like ground textures in a 3D world where you want high-quality filtering but no repeating patterns. | +| [**AnisotropicWrap**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.AnisotropicWrap) | Combines Anisotropic filter mode with the Wrap addressing mode. | Terrain textures in 3D games where you need high-quality filtering and repeating patterns over large surfaces. | +| [**LinearClamp**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.LinearClamp) | Combines Linear filter mode with the Clamp addressing mode. | UI elements and single sprites where you want smooth scaling but no repeating patterns. Good for realistic graphics that need to scale. | +| [**LinearWrap**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.LinearWrap) | Combines Linear filter mode with the Wrap addressing mode. | Scrolling backgrounds with smooth transitions, like water or cloud textures that need to tile seamlessly with blended edges. | +| [**PointClamp**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.PointClamp) | Combines Point filter mode with the Clamp addressing mode. | Pixel art sprites and UI elements where you want to preserve crisp pixel edges without any blurring when scaled. Default for most 2D games. | +| [**PointWrap**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.PointWrap) | Combines Point filter mode with the Wrap addressing mode. | Tiled pixel art backgrounds and patterns where you want crisp pixels and repeating patterns | + +When using the [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch), you specify which sampler state you want to use as the `samplerState` parameter for the [**SpriteBatch.Begin**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Begin(Microsoft.Xna.Framework.Graphics.SpriteSortMode,Microsoft.Xna.Framework.Graphics.BlendState,Microsoft.Xna.Framework.Graphics.SamplerState,Microsoft.Xna.Framework.Graphics.DepthStencilState,Microsoft.Xna.Framework.Graphics.RasterizerState,Microsoft.Xna.Framework.Graphics.Effect,System.Nullable{Microsoft.Xna.Framework.Matrix})) method + +```cs +// Example of using the Point Clamp sampler state +spriteBatch.Begin(samplerState: SamplerState.PointClamp); +``` + +> [!NOTE] +> The default sampler state for [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch) is [**SamplerState.LinearClamp**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.LinearClamp) in MonoGame, though [**SamplerState.PointClamp**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.PointClamp) is often preferred for pixel art games to prevent blurring. + +## Adding a Scrolling Background to the Title Scene + +We will now update title scene of our game by adding a scrolling background pattern using [**SamplerState.PointWrap**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.PointWrap). By using the Wrap addressing mode, we can create a large scrolling background using only a small texture. When the texture is drawn with a destination rectangle larger than the texture itself, the Wrap mode will automatically tile the texture to fill the space. By adjusting the source rectangle over time, we can create a scrolling effect with minimal effort. + +First, download the following image of a repeatable background pattern by right-clicking it and saving it as `background-pattern.png` in the `Content/images` folder of the game project: + +| ![Figure 18-9: The repeatable background pattern we will use for the title screen](./images/background-pattern.png) | +| :---------------------------------------------------------------------------------------------------------------: | +| **Figure 18-9: The repeatable background pattern we will use for the title screen** | + +Next, add this texture to your content project using the MGCB Editor: + +1. Open the `Content.mgcb` content project file in the MGCB Editor. +2. Right-click the images folder and choose `Add > Existing item...`. +3. Navigate to and select the `background-pattern.png` file. +4. Save the changes and close the MGCB Editor. + +| ![Figure 18-10: The MGCB Editor with the *background-pattern* image added](./images/mgcb-editor.png) | +| :-------------------------------------------------------------------------------------------------: | +| **Figure 18-10: The MGCB Editor with the *background-pattern* image added** | + +### Updating the Title Scene + +Now that we have the background pattern texture added, we can update the `TitleScene` class to implement the scrolling background. Open the `TitleScene.cs` file in the game project and update it to the following + +[!code-csharp[](./snippets/titlescene.cs?highlight=39-50,76-81,92-93,104-113,120-123)] + +The key changes here are + +- The `_backgroundPattern` field was added to store a reference to the texture of the background pattern once its loaded. +- The `_backgroundDestination` field was added to define the destination rectangle to draw the background pattern to. +- The `_backgroundOffset` field was added to apply an offset to the source rectangle when rendering the background pattern to give it the appearance that it is scrolling. +- The `_scrollSpeed` field was added to set the speed at which the background pattern scrolls. +- In `Initialize`, the initial offset of the background is set to [**Vector2.Zero**](xref:Microsoft.Xna.Framework.Vector2.Zero) and the background destination rectangle is set to the bounds of the screen. +- In `LoadContent`, the *background-pattern* texture is loaded and stored in `_backgroundPattern`. +- In `Update`, the X and Y offset for the background source rectangle is calculated by adjusting the based on the scroll speed multiplied by the delta time. Modulo division is then used to ensure that the new offset calculations remain within the width and height bounds of the background texture so that the wrap is seamless. +- In `Draw`, a new sprite batch begin/end block is added that uses [**SamplerState.PointWrap**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.PointWrap) and draws the background pattern to the destination rectangle using a source rectangle with the offset calculations. + +> [!NOTE] +> We use two separate sprite batch begin/end blocks for this. The first uses [**SamplerState.PointWrap**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.PointWrap) to draw the background and the second uses [**SamplerState.PointClamp**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.PointClamp) to draw the rest of the scene. +> +> This separation is necessary because changing the sampler state requires ending the current sprite batch and beginning a new one. + +Running the game now with these changes, the title screen now has a scroll background that adds more visual depth and interest to it than just the plain colored background we had before. + +| ![Figure 18-11: The title screen now with the repeating background texture of the slime and bat scrolling diagonally down and to the right](./videos/titlescreen.webm) | +| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 18-11: The title screen now with the repeating background texture of the slime and bat scrolling diagonally down and to the right** | + +## Conclusion + +In this chapter, you accomplished the following: + +- Learned about texture coordinates and how they map from normalized 0.0 to 1.0 space to actual pixel locations. +- Understood the difference between various filtering modes (Point, Linear, Anisotropic) and their visual impact. +- Explored different addressing modes (Wrap, Mirror, Clamp, Border) and when to use each. +- Discovered how to use predefined sampler states to simplify common rendering tasks. +- Implemented a scrolling background pattern using [**SamplerState.PointWrap**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.PointWrap). + +## Test Your Knowledge + +1. What is the difference between normalized texture coordinates and pixel coordinates? + + :::question-answer + Normalized texture coordinates always range from 0.0 to 1.0 regardless of the texture's actual pixel dimensions. The top-left corner is (0.0, 0.0) and the bottom-right is (1.0, 1.0). Pixel coordinates, on the other hand, directly reference specific pixel locations within the texture using integer values based on the actual texture dimensions. MonoGame automatically converts between these coordinate systems when drawing textures. + ::: + +2. Which filtering mode would be most appropriate for a pixel art game, and why? + + :::question-answer + Point filtering (also called nearest neighbor) is most appropriate for pixel art games. It selects the closest pixel when scaling rather than blending neighboring pixels, which preserves the crisp, pixelated aesthetic that defines pixel art. Linear or Anisotropic filtering would blur the intentionally sharp edges of pixel art graphics. + ::: + +3. Why do we use modulo (%) operation on the background offset values when implementing the scrolling background? + + :::question-answer + The modulo operation ensures that the offset values always remain within the bounds of the original texture dimensions. This prevents potential graphical artifacts that could appear when the offset exceeds the texture size, and it guarantees seamless wrapping as the background continuously scrolls. Without this, the background pattern might show visible seams or discontinuities when it repeats. + ::: + +4. Why do we need to use two separate sprite batch begin/end blocks when drawing the background and the other elements in the title scene? + + :::question-answer + We need separate blocks because changing the sampler state requires ending the current batch and beginning a new one. Since we want to use [**SamplerState.PointWrap**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.PointWrap) for the tiling background but [**SamplerState.PointClamp**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.PointClamp) for the UI elements, we must use two distinct sprite batch blocks with different sampler state settings. Using a single batch would apply the same sampler state to all drawn elements. + ::: + +5. How does using a tiled background with [**SamplerState.PointWrap**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.PointWrap) compare to manually drawing multiple copies of a texture to create a background? + + :::question-answer + Using [**SamplerState.PointWrap**](xref:Microsoft.Xna.Framework.Graphics.SamplerState.PointWrap) offers several advantages: + + - It requires only a single draw call instead of multiple calls for each tile. + - No need to calculate positions for each individual tile + - Manually drawing multiple copies would be more code-intensive, less performant, and harder to maintain, especially for animations. + + ::: diff --git a/articles/tutorials/building_2d_games/18_texture_sampling/snippets/titlescene.cs b/articles/tutorials/building_2d_games/18_texture_sampling/snippets/titlescene.cs new file mode 100644 index 00000000..e3d9c982 --- /dev/null +++ b/articles/tutorials/building_2d_games/18_texture_sampling/snippets/titlescene.cs @@ -0,0 +1,151 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary; +using MonoGameLibrary.Scenes; + +namespace DungeonSlime.Scenes; + +public class TitleScene : Scene +{ + private const string DUNGEON_TEXT = "Dungeon"; + private const string SLIME_TEXT = "Slime"; + private const string PRESS_ENTER_TEXT = "Press Enter To Start"; + + // The font to use to render normal text. + private SpriteFont _font; + + // The font used to render the title text. + private SpriteFont _font5x; + + // The position to draw the dungeon text at. + private Vector2 _dungeonTextPos; + + // The origin to set for the dungeon text. + private Vector2 _dungeonTextOrigin; + + // The position to draw the slime text at. + private Vector2 _slimeTextPos; + + // The origin to set for the slime text. + private Vector2 _slimeTextOrigin; + + // The position to draw the press enter text at. + private Vector2 _pressEnterPos; + + // The origin to set for the press enter text when drawing it. + private Vector2 _pressEnterOrigin; + + // The texture used for the background pattern. + private Texture2D _backgroundPattern; + + // The destination rectangle for the background pattern to fill. + private Rectangle _backgroundDestination; + + // The offset to apply when drawing the background pattern so it appears to + // be scrolling. + private Vector2 _backgroundOffset; + + // The speed that the background pattern scrolls. + private float _scrollSpeed = 50.0f; + + public override void Initialize() + { + // LoadContent is called during base.Initialize(). + base.Initialize(); + + // While on the title screen, we can enable exit on escape so the player + // can close the game by pressing the escape key. + Core.ExitOnEscape = true; + + // Set the position and origin for the Dungeon text. + Vector2 size = _font5x.MeasureString(DUNGEON_TEXT); + _dungeonTextPos = new Vector2(640, 100); + _dungeonTextOrigin = size * 0.5f; + + // Set the position and origin for the Slime text. + size = _font5x.MeasureString(SLIME_TEXT); + _slimeTextPos = new Vector2(757, 207); + _slimeTextOrigin = size * 0.5f; + + // Set the position and origin for the press enter text. + size = _font.MeasureString(PRESS_ENTER_TEXT); + _pressEnterPos = new Vector2(640, 620); + _pressEnterOrigin = size * 0.5f; + + // Initialize the offset of the background pattern at zero + _backgroundOffset = Vector2.Zero; + + // Set the background pattern destination rectangle to fill the entire + // screen background + _backgroundDestination = Core.GraphicsDevice.PresentationParameters.Bounds; + } + + public override void LoadContent() + { + // Load the font for the standard text. + _font = Core.Content.Load("fonts/04B_30"); + + // Load the font for the title text + _font5x = Content.Load("fonts/04B_30_5x"); + + // Load the background pattern texture. + _backgroundPattern = Content.Load("images/background-pattern"); + } + + public override void Update(GameTime gameTime) + { + // If the user presses enter, switch to the game scene. + if (Core.Input.Keyboard.WasKeyJustPressed(Keys.Enter)) + { + Core.ChangeScene(new GameScene()); + } + + // Update the offsets for the background pattern wrapping so that it + // scrolls down and to the right. + float offset = _scrollSpeed * (float)gameTime.ElapsedGameTime.TotalSeconds; + _backgroundOffset.X -= offset; + _backgroundOffset.Y -= offset; + + // Ensure that the offsets do not go beyond the texture bounds so it is + // a seamless wrap + _backgroundOffset.X %= _backgroundPattern.Width; + _backgroundOffset.Y %= _backgroundPattern.Height; + } + + public override void Draw(GameTime gameTime) + { + Core.GraphicsDevice.Clear(new Color(32, 40, 78, 255)); + + // Draw the background pattern first using the PointWrap sampler state. + Core.SpriteBatch.Begin(samplerState: SamplerState.PointWrap); + Core.SpriteBatch.Draw(_backgroundPattern, _backgroundDestination, new Rectangle(_backgroundOffset.ToPoint(), _backgroundDestination.Size), Color.White * 0.5f); + Core.SpriteBatch.End(); + + // Begin the sprite batch to prepare for rendering. + Core.SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + // The color to use for the drop shadow text. + Color dropShadowColor = Color.Black * 0.5f; + + // Draw the Dungeon text slightly offset from it is original position and + // with a transparent color to give it a drop shadow + Core.SpriteBatch.DrawString(_font5x, DUNGEON_TEXT, _dungeonTextPos + new Vector2(10, 10), dropShadowColor, 0.0f, _dungeonTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + + // Draw the Dungeon text on top of that at its original position + Core.SpriteBatch.DrawString(_font5x, DUNGEON_TEXT, _dungeonTextPos, Color.White, 0.0f, _dungeonTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + + // Draw the Slime text slightly offset from it is original position and + // with a transparent color to give it a drop shadow + Core.SpriteBatch.DrawString(_font5x, SLIME_TEXT, _slimeTextPos + new Vector2(10, 10), dropShadowColor, 0.0f, _slimeTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + + // Draw the Slime text on top of that at its original position + Core.SpriteBatch.DrawString(_font5x, SLIME_TEXT, _slimeTextPos, Color.White, 0.0f, _slimeTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + + // Draw the press enter text + Core.SpriteBatch.DrawString(_font, PRESS_ENTER_TEXT, _pressEnterPos, Color.White, 0.0f, _pressEnterOrigin, 1.0f, SpriteEffects.None, 0.0f); + + // Always end the sprite batch when finished. + Core.SpriteBatch.End(); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/18_texture_sampling/videos/titlescreen.webm b/articles/tutorials/building_2d_games/18_texture_sampling/videos/titlescreen.webm new file mode 100644 index 00000000..20aee437 Binary files /dev/null and b/articles/tutorials/building_2d_games/18_texture_sampling/videos/titlescreen.webm differ diff --git a/articles/tutorials/building_2d_games/19_user_interface_fundamentals/images/user_interface_anchor.svg b/articles/tutorials/building_2d_games/19_user_interface_fundamentals/images/user_interface_anchor.svg new file mode 100644 index 00000000..f3f5deff --- /dev/null +++ b/articles/tutorials/building_2d_games/19_user_interface_fundamentals/images/user_interface_anchor.svg @@ -0,0 +1,2 @@ +User Interface AnchoringTop LeftAnchors to top left corner +TopAnchors to the top edgeTop RightAnchors to the top right cornerLeftAnchors to the left edgeCenterAnchors to the centerRightAnchors to the right edgeBottom LeftAnchors to the bottom left cornerBottomAnchors to the bottom edgeBottom RightAnchors to the bottom right corner diff --git a/articles/tutorials/building_2d_games/19_user_interface_fundamentals/images/user_interface_anchor_positioning.svg b/articles/tutorials/building_2d_games/19_user_interface_fundamentals/images/user_interface_anchor_positioning.svg new file mode 100644 index 00000000..f0e3f221 --- /dev/null +++ b/articles/tutorials/building_2d_games/19_user_interface_fundamentals/images/user_interface_anchor_positioning.svg @@ -0,0 +1,2 @@ +Anchor PositioningParent container size increases, but the element right anchored maintains the constant -5 pixel marginRight anchor with an X coordinate +position of -5 pixels diff --git a/articles/tutorials/building_2d_games/19_user_interface_fundamentals/images/user_interface_dock.svg b/articles/tutorials/building_2d_games/19_user_interface_fundamentals/images/user_interface_dock.svg new file mode 100644 index 00000000..b54e937f --- /dev/null +++ b/articles/tutorials/building_2d_games/19_user_interface_fundamentals/images/user_interface_dock.svg @@ -0,0 +1,10 @@ +Fill VerticallyStretchs across the parent's height, +filling vertically.FillAnchors to the center and fills +the entire parent vertically and +horizontallyUser Interface Docking +Stretches across the parent's width +filling horiziontallyFill HorizontallyTopAnchors to the top edge +and fills horizontallyAnchors to the bottom edge +and fills horizontallyBottomAnchors to the left edge +and fills verticallyLeftAnchors to the right edge +and fills verticallyRight diff --git a/articles/tutorials/building_2d_games/19_user_interface_fundamentals/index.md b/articles/tutorials/building_2d_games/19_user_interface_fundamentals/index.md new file mode 100644 index 00000000..760290b3 --- /dev/null +++ b/articles/tutorials/building_2d_games/19_user_interface_fundamentals/index.md @@ -0,0 +1,150 @@ +--- +title: "Chapter 19: User Interface Fundamentals" +description: "Learn the core principles of game user interface design." +--- + +A game's user interface (UI) allows players to interact with the game beyond just controlling the character. UI elements include menus, buttons, panels, labels, and various other interactive components that provide information and control options to the player. + +In this chapter you will: + +- Learn the basics of user interface design in games. +- Understand different UI types and their purposes. +- Explore UI layout approaches and positioning strategies. +- Understand the parent-child relationship for UI elements. +- Learn about accessibility considerations in game UI design. + +We will first start by understanding what a user interface is and how it functions in game development. + +## Understanding Game User Interfaces + +A user interface in games serves as a bridge between the player and the game's systems. Well designed UIs help players navigate the game's mechanics, understand their current status, and make informed decisions. For new game developers, understanding UI principles is crucial because even the most mechanically sound game can fail if players ca not effectively interact with it. + +Game UIs consist of various visual elements that serve different purposes: + +1. **Information Display**: Elements like health bars, score counters, or minimap displays provide players with game state information. These elements help players understand their progress, resources, and current status without interrupting gameplay. +2. **Interactive Controls**: Buttons, sliders, checkboxes, and other interactive elements allow players to make choices, adjust settings, or navigate through different sections of the game. These elements should provide clear visual feedback when interacted with to confirm the player's actions. +3. **Feedback Mechanisms**: Visual effects like highlighting, color changes, or animations that respond to player actions help confirm that input was received. This feedback loop creates an intuitive and responsive feel for the UI in your game. + +User interfaces for games can be categorized into two main types, each with their own design considerations: + +- **Diegetic UI**: These elements exist within the game world itself and are often part of the narrative. Examples include a health meter integrated into a character's suit, ammunition displayed on a weapon's holographic sight, or the dashboard instruments in the cockpit of a racing game. A Diegetic UI can enhance immersion by making interface elements feel like natural parts of the game world. +- **Non-diegetic UI**: These elements exist outside the game world, overlaid on top of the gameplay. Traditional menus, health bars in the corner of the screen, and score displays are common examples. While less immersive than a diegetic UI, non-diegetic elements are often clearer and easier to read. + +## UI Layout Systems + +When designing and implementing game UI systems, developers must decide how UI elements will be positioned on the screen. Two primary approaches exist, each with distinct advantages and trade-offs: + +1. **Absolute Positioning**: In this approach, each UI element is placed at specific coordinates on the screen. Elements are positioned using exact locations, which gives precise control over the layout. This approach is straightforward to implement and works well for static layouts where elements do not need to adjust based on screen size or content changes. The main disadvantage of absolute positioning is its lack of flexibility, as iterating on design can be more difficult since one change may have cascading effects on other elements. If the screen resolution changes or if an element's size changes, manual adjustments to positions are often necessary to maintain the desired layout. + +2. **Layout engines**: These system position UI elements relative to one another using rules and constraints. Elements might be positioned using concepts like "center", "align to parent", or "flow horizontally with spacing". Layout engines add complexity but provide flexibility. The advantage of layout engines is adaptability to different screen sizes and content changes. However, they require more initial setup and can be more complex to implement from scratch. + +## Parent-Child Relationships + +Parent-child relationships are a part of many UI system. This relationship is implemented with UI elements containing other UI elements, creating a tree-like structure. This hierarchial approach mirrors how interface elements naturally group together in designs. + +For example, a settings panel might contain multiple buttons, labels, and sliders. By making these elements children of the panel, they can be managed as a cohesive unit. This organizational structure provides several significant advantages: + +- **Inheritance of Properties**: Child elements can automatically inherit certain properties from their parents. For instance, if a parent element is hidden or disabled, all its children can be hidden or disabled as well. This cascading behavior simplifies state management across complex interfaces. +- **Relative Positioning**: Child elements can be positioned relative to their parents rather than relative to the screen. This means you can place elements within a container and then move the entire container as a unit without having to update each child's position individually. +- **Simplified State Management**: Actions on parent elements can automatically propagate to their children. For example, disabling a menu panel can automatically disable all buttons within it, preventing interaction with elements that should be active. +- **Batch Operations**: Operations like drawing and updating can be performed on a parent element and automatically cascade to all children, reducing the need for repetitive code. +- **Logical Grouping**: The hierarchy naturally models the conceptual grouping of UI elements, making the code structure more intuitive and easier to maintain. + +## Anchoring and Docking + +In UI systems, two important concepts help with positioning elements: anchoring and docking. + +### Anchoring + +Anchoring allows you to position UI elements relative to specific reference points on their parents. The following diagram demonstrates common anchor points: + +| ![Figure 19-1: Diagram showing common anchor points](./images/user_interface_anchor.svg) | +| :--------------------------------------------------------------------------------------: | +| **Figure 19-1: Diagram showing common anchor points** | + +When you set an anchor point, the elements' position coordinates become relative to that anchor point. For example with a "Right" anchor and an X value of *-5*, you element would position itself *5 pixels* to the left of the parent's right edge, creating a consistent margin regardless of the parent's size, as demonstrated in the following diagram: + +| ![Figure 19-2: Diagram showing anchored element positioning relative to anchor regardless of parent size](./images/user_interface_anchor_positioning.svg) | +| :-------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 19-2: Diagram showing anchored element positioning relative to anchor regardless of parent size** | + +### Docking + +Docking takes anchoring a step further by also adjusting an element's size to fill available space. The following diagram demonstrates common docking options: + +| ![Figure 19-3: Diagram showing the common docking options](./images/user_interface_dock.svg) | +| :------------------------------------------------------------------------------------------: | +| **Figure 19-3: Diagram showing the common docking options** | + +These positioning strategies enable you to create layouts that can maintain their visual relationships even when parent elements change size or position. + +## Accessibility in Game UI + +Creating accessible user interfaces is an essential aspect of inclusive game design. Accessibility ensures that your game can be played by a broader audience, including players with visual acuity or other specific needs. When designing your UI system, consider some of these key accessibility principles: + +### Visual Accessibility + +- **Color contrast**: Ensure sufficient contrast between text and backgrounds. +- **Use shapes**: Do not rely solely on color to convey important information; add shapes, patterns, or text labels as well. For example, if displaying warning text, also use something such as the common warning sign ⚠️. +- **Text size and scaling**: Allow players to adjust text size or implement a UI scaling option. +- **Internationalization (i18n)**: Consider how your UI might be interpreted across different cultures and regions. Number formatting can vary significantly - some regions use periods for thousands separators (1.000.000) while others use commas (1,000,000). Control symbolism also differs culturally; for example, on console controllers, the Cross button typically means "Select" in Western regions but "Cancel" in Japan, with Circle having the opposite meaning. + +### Input Accessibility + +- **Input redundancy**: Support multiple input methods for the same action. This ensures players can interact with UI elements using their preferred input devices. +- **Reduce input precision requirements**: Implement generous hitboxes for clickable UI elements to help players with motor control difficulties. + +### Testing for Accessibility + +The most effect way to ensure accessibility is through testing under different circumstances and with diverse users: + +- Test your Ui using only keyboard navigation. +- Try playing without sound. +- Check your UI with a color blindness simulator. +- Adjust the display scale to simulate low vision. +- Get feedback from player with different abilities. + +By considering accessibility early in development rather than as an afterthought, you create games that can be enjoyed by more players while also often improving the experience for everyone. + +## Conclusion + +In this chapter, you learned the fundamentals of user interface design for games. You explored the different types of UI elements and their purposes, understood the benefits of parent-child relationships in UI hierarchies, and learned about different positioning strategies like anchoring and docking. + +You also discovered the importance of accessible UI design and how to implement practices that make your game playable for a wider audience. These foundational concepts will serve as the building blocks for implementing the UI in our game. + +In the next chapter, we will put these concepts into practice by implementing a UI system using Gum, a specialized UI framework that will help us create interactive menus, buttons, and other UI elements for our game. + +## Test Your Knowledge + +1. What are the two main types of game user interfaces, and how do they differ? + + :::question-answer + The two main types are: + - **Diegetic UI**: Elements that exist within the game world itself and are part of the narrative (like health meters integrated into a character's suit or cockpit instruments in racing games). These enhance immersion by making UI feel like a natural part of the game world. + - **Non-diegetic UI**: Elements that exist outside the game world, overlaid on top of gameplay (like traditional menus, health bars in screen corners, score displays). While less immersive, they are often clearer and easier to read. + ::: + +2. What are the some advantages of using a parent-child relationship in UI systems? + + :::question-answer + - **Inheritance of properties**: visual states cascade parent to children. + - **Relative positioning**: Child elements are positioned relative to their parents. + - **Simplified state management**: Parent states affect children automatically. + - **Batch operations**: Update and draw calls propagate through the hierarchy. + - **Logical grouping**: Mirrors the conceptual organization of UI elements. + ::: + +3. How do anchoring and docking differ in UI layout systems? + + :::question-answer + - **Anchoring**: Positions UI elements relative to specific reference points on their parent (like TopLeft, Center, BottomRight) without changing the element's size. An element's position coordinates become relative to the chosen anchor point. + - **Docking**: Takes anchoring further by also adjusting an element's size to fill available space. For example, docking to the top means the element fills the parent horizontally while staying at the top, while "Fill" docking means the element expands to fill the entire parent area. + ::: + +4. What are some accessibility considerations that should be implemented in game UI systems? + + :::question-answer + - **Visual accessibility**: High contrast colors, not relying solely on color for information, adjustable text size and UI scaling, and internationalization support. + - **Input accessibility**: Support for multiple input methods and reduced precision requirements. + - **Testing practices**: Ensure the UI works with keyboard only navigation, without sound, and with simulated visual impairments. + ::: diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/files/ui.wav b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/files/ui.wav new file mode 100644 index 00000000..63e8941e Binary files /dev/null and b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/files/ui.wav differ diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/images/mgcb-editor.png b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/images/mgcb-editor.png new file mode 100644 index 00000000..81e51874 Binary files /dev/null and b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/images/mgcb-editor.png differ diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/images/pause-unstyled.png b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/images/pause-unstyled.png new file mode 100644 index 00000000..3a592da4 Binary files /dev/null and b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/images/pause-unstyled.png differ diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/images/title-unstyled.png b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/images/title-unstyled.png new file mode 100644 index 00000000..9e4ad987 Binary files /dev/null and b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/images/title-unstyled.png differ diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/index.md b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/index.md new file mode 100644 index 00000000..e947c249 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/index.md @@ -0,0 +1,688 @@ +--- +title: "Chapter 20: Implementing UI with Gum" +description: "Learn how to integrate and use the Gum UI framework to create functional menus, buttons, and sliders for your MonoGame projects." +--- + +In the [previous chapter](../19_user_interface_fundamentals/index.md) we explored the fundamental concepts of user interface design. Now we are ready to put these principles into practice by implementing a UI system for our game. While it is possible to build a UI system from scratch, we will take advantage of Gum, a specialized UI framework that simplifies many of the complex aspects of UI implementation. + +In this chapter you will: + +* Install and configure the Gum NuGet package. +* Learn about Gum's core concepts including Forms and Visuals +* Implement UI elements for our game's title scene. +* Create a pause menu for the gameplay scene. +* Handle input from keyboard, mouse, and gamepads +* Integrate the UI system with our existing game architecture. + +> [!IMPORTANT] +> While GUM is used in this tutorial it is only one of many UI libraries available to the MonoGame community, some notable others are [EmptyKeys](https://github.com/EmptyKeys/UI_Engines), [GeonBit.UI](https://github.com/RonenNess/GeonBit.UI), as well as entire Game Frameworks/Engines like [Nez}(https://github.com/prime31/Nez) that have their own built in UI systems. +> +> Check out the [MonoGame Resources](https://monogame.net/resources/) page, as well as [awesome-monogame](https://github.com/aloisdeniel/awesome-monogame) from [Alois Deniel](https://github.com/aloisdeniel) for even more community offerings. + +## What is Gum? + +Gum is a powerful UI layout engine and framework. It provides a flexible, efficient system capable of producing virtually any UI layout you might need in your games. While originally developed alongside the FlatRedBall game engine, Gum has evolved to work seamlessly with multiple platforms, including MonoGame, which we will be using in this tutorial. + +### Why Use Gum? + +Creating a UI system from scratch requires solving many complex problems: + +1. **Layout Management**: Calculating positions for elements that need to adapt to different screen sizes or content changes. +2. **Input Handling**: Detecting and responding to mouse, keyboard, and gamepad inputs across multiple UI elements. +3. **Visual State Management**: Changing appearances based on user interactions (hovering, focusing, clicking). +4. **Component Hierarchy**: Managing parent-child relationships between elements. + +Gum addresses these challenges with ready-made solutions, allowing us to focus on the specific needs of our game rather than reinventing the UI wheel. While MonoGame provides the basic tools for drawing graphics and detecting input, it does not include high-level UI abstractions; this is where tools like Gum fill the gap. + +> [!IMPORTANT] +> This tutorial uses the Gum NuGet package to help with layout and responding to user interactions. This tutorial does not require the use of the Gum editor, we will be doing everything in code. +> +> Keep in mind, that while it is possible to build a full UI system without any external dependencies, creating a layout engine is complicated and beyond the scope of this tutorial. Instead, we will be taking advantage of the Gum NuGet package. +> +> Gum is a powerful system enabling the creation of virtually any game UI, and we will be covering some of the basics of its use in this tutorial. The full Gum documentation can be found here: [https://docs.flatredball.com/gum/code/monogame](https://docs.flatredball.com/gum/code/monogame) + +## Gum Concepts + +Before we dive into implementation, we will explore the core concepts that Gum provides. Gum simplifies UI development by providing ready-made controls and layout systems that would otherwise require significant effort to build from scratch. + +### Understanding Gum's Structure + +Gum organizes UI elements in a hierarchical tree structure, similar to how HTML organizes web elements or how GUI frameworks like WPF or JavaFX structure their interfaces. This hierarchy consists of: + +1. **The Root Element**: The topmost container in the hierarchy that serves as the entry point for all UI elements. +2. **Containers**: Elements that can hold other elements (like panels or screens). +3. **Controls**: Interactive elements that respond to user input (like buttons or sliders). +4. **Visuals**: The actual graphical representations of UI elements (like text, images, or shapes). + +When a game using Gum runs, this hierarchy is maintained in memory, with each element knowing its parent and children. The framework automatically handles the flow of events through this hierarchy and manages the drawing of elements according to their positions in the tree. + +### Gum Root Element + +All Gum elements must be directly or indirectly added to Gum's root container. This can be done directly wth the `AddToRoot()` method: + +```cs +// Creating a panel and adding it to the root +Panel mainMenuPanel = new Panel(); +mainMenuPanel.AddToRoot(); +``` + +Or it can be done indirectly by adding a control as a child of an element that has been added to Gum's root container: + +```cs +// Creating a panel and adding it to the root +Panel mainMenuPanel = new Panel(); +mainMenuPanel.AddToRoot(); + +// Creating a button and adding it as a child element of the panel +// which indirectly connects it to Gum's root container +Button startButton = new Button(); +mainMenuPanel.AddChild(startButton); +``` + +Gum's root element can also be cleared at any time to remove all UI elements: + +```cs +// Clear all children from Gum's root container. +GumService.Default.Root.Children.Clear(); +``` + +This can be useful when navigating between different scenes to ensure UI elements do not persist from previous scenes: + +```cs +public class GameScene +{ + public override void Initialize() + { + // Clear all children from Gum's root container that may have been added + // during the previous scene + GumService.Default.Root.Children.Clear(); + + // Now that it has been cleared, initialize the UI for this scene + InitializeUI(); + } +} +``` + +### Anchoring + +In the previous chapter we discussed [anchoring](../19_user_interface_fundamentals/index.md#anchoring), a fundamental UI concept that allows you to position elements relative to specific reference points of their parents. Gum supports anchoring of Forms controls through the `Anchor()` method. + +```cs +// Creating a panel and adding it to the root +Panel mainMenuPanel = new Panel(); +mainMenuPanel.AddToRoot(); + +// Creating a button and adding it as a child of the panel +// anchored ot the bottom-left of the panel +Button startButton = new Button(); +startButton.Anchor(Gum.Wireframe.Anchor.BottomLeft); +mainMenuPanel.AddChild(startButton); +``` + +The following anchor types are supported by Gum: + +| Anchor | Gum Value | +| ----------- | ---------------------------------- | +| TopLeft | `Gum.Wireframe.Anchor.TopLeft` | +| Top | `Gum.Wireframe.Anchor.Top` | +| TopRight | `Gum.Wireframe.Anchor.TopRight` | +| Left | `Gum.Wireframe.Anchor.Left` | +| Center | `Gum.Wireframe.Anchor.Center` | +| Right | `Gum.Wireframe.Anchor.Right` | +| BottomLeft | `Gum.Wireframe.Anchor.BottomLeft` | +| Bottom | `Gum.Wireframe.Anchor.Bottom` | +| BottomRight | `Gum.Wireframe.Anchor.BottomRight` | + +### Docking + +In the previous chapter, we also discussed [docking](../19_user_interface_fundamentals/index.md#docking), a fundamental UI concept that adjusts an element's size to fill the available space. Gum supports the docking of Forms controls through their `Dock()` method. + +```cs +// Creating a panel and adding it to the root +Panel mainMenuPanel = new Panel(); +mainMenuPanel.AddToRoot(); + +// Docking the panel to fill the entire root space +mainMenuPanel.Dock(Gum.Wireframe.Dock.Fill); +``` + +The following docking modes are supported by Gum: + +| Anchor | Gum Value | Description | +| ---------------- | ------------------------------------- | ----------------------------------------------------------------------------------- | +| Top | `Gum.Wireframe.Dock.Top` | Anchors to the top edge and fills horizontally. | +| Left | `Gum.Wireframe.Dock.Left` | Anchors to the left edge and fills vertically. | +| Right | `Gum.Wireframe.Dock.Right` | Anchors to the ridge edge and fills vertically. | +| Bottom | `Gum.Wireframe.Dock.Bottom` | Anchors to the bottom edge and fills horizontally. | +| Fill | `Gum.Wireframe.Dock.Fill` | Anchors to the center and fills the entire parent area vertically and horizontally. | +| FillHorizontally | `Gum.Wireframe.Dock.FillHorizontally` | Stretches across the parent's width, filling horizontally. | +| FillVertically | `Gum.Wireframe.Dock.FillVertically` | Stretches across the parent's height, filling vertically. | +| SizeToChildren | `Gum.Wireframe.Dock.SizeToChildren` | Automatically sizes vertically and horizontally based on contained child element. | + +### Forms and Visuals + +Gum provides two types of objects: **Forms** and **Visuals**. + +* Forms controls are typical interactive UI elements such as buttons, sliders, and text boxes that handle user interaction through mouse, gamepad, and keyboard inputs. These controls come with built-in functionality; a button responds visually when focused, while a slider changes its value when clicked on its *track*. By using these standardized components, you can maintain consistency throughout your UI implementation. + +* Forms controls provide customization through their `Visual` property, which serves as a gateway to modifying their appearance and layout. With this property, you can move, resize, restyle, and even completely replace visuals through code. As we will see when building our UI in the next chapter, this separation between functionality and presentation allows us to create consistent behaviors while adapting the visual style to match our game's aesthetic. + +For now, we will examine some of the Forms control types we will use in this chapter. + +#### Panel + +Panels serve as invisible containers that group related UI elements together. Unlike visible elements that display graphics, panels focus on organization and layout management. + +A panel provides several key functions: + +* Groups related elements for easier management. +* Controls visibility for entire sections of UI at once. +* Establishes a coordinate system for child elements. +* Provides a foundation for layout management. + +Panels are especially useful for creating distinct UI screens, by toggling the visibility of different panels you can implement complete UI state changes with minimal code: + +```cs +// Change the state of the UI by hiding one panel and showing another. +mainMenuPanel.IsVisible = false; +optionsPanel.IsVisible = true; +``` + +A common pattern is to set a panel's docking to `Fill`, which makes it span the entire available area: + +```cs +// Make the panel fill the entire screen +mainMenuPanel.Dock(Gum.Wireframe.Dock.Fill); +``` + +This creates a consistent coordinate space for all child elements, allowing them to be positioned relative to the screen. + +#### Button + +The `Button` Forms control type is the primary interactive control for triggering actions in your UI. + +Buttons provide: + +* Responses to clicks from mouse, touch, keyboard, or gamepad input. +* Visual feedback when focused or hovered. +* Raises a `Click` event when activated. + +Buttons can be positioned using anchoring to create layouts that adapt to different screen sizes: + +```cs +// Creating a button that is anchored to the bottom left. +Button startButton = new Button; +startButton.Anchor(Gum.Wireframe.Anchor.BottomLeft); + +// Set the X and Y position so it is 20px from the left edge +// and 20px from the bottom edge. +startButton.Visual.X = 20; +startButton.Visual.Y = -20; +``` + +The `Click` event is raised whenever the button is activated and provides a standard way to respond regardless of input device: + +```cs +startButton.Click += (sender, args) => +{ + // Handle button click... + StartGame(); +}; +``` + +> [!NOTE] +> The `Click` event for a button is triggered automatically when it is left-clicked by a mouse or pushed via touch controls. For keyboard and gamepad input, it can only be triggered when the button has focus, which typically happens when the player navigates to it using the tab key or controller. + +#### Slider + +The `Slider` Forms control type allows users to select a numeric value from a continuous range. A slider: + +* Displays and modifies a `Value` property constrained between a `Minimum` and `Maximum` value. +* Responds to mouse clicks on its track or by dragging its thumb. +* Supports keyboard and gamepad input for incremental adjustments. +* Raises events when its value changes. + +Basic slider setup includes defining its range and establishing event handlers: + +```cs +Slider volumeSlider = new Slider(); +volumeSlider.Minimum = 0.0f; +volumeSlider.Maximum = 1.0f; +volumeSlider.Value = 0.5f; +volumeSlider.SmallChange = 0.1f; +volumeSlider.LargeChange = 0.2f; +``` + +The `SmallChange` property sets the increment for keyboard and gamepad adjustments, while the `LargeChange` property determines the increment when clicking directly on the slider *track*. + +Sliders provide several events for different interaction scenarios: + +* `ValueChanged`: Fires continuously as the value changes (useful for live previews). +* `ValueChangeCompleted`: Fires once when the user finishes adjusting the value (useful for applying final settings). + +```cs +volumeSlider.ValueChanged += (sender, arg) => +{ + // Handle value changed event... + UpdateVolume(volumeSlider.Value); +}; + +volumeSlider.ValueChangedCompleted += (sender, arg) => +{ + // Handle value change completed event... + UpdateVolume(volumeSlider.Value); + + // Useful to do things like this here since this fires once + // the slider value change has completed so it is not constantly + // triggering ui sound effects. + PlayUISoundEffect(); +}; +``` + +### Property Changes vs States + +Gum allows you to customize visuals in two ways: + +* Direct property assignment +* Using states. + +With simple property changes, you can directly assign values in code. For example, the following code example changes the width of a button: + +```cs +startButton.Visual.Width = 100; +``` + +Direct property assignment works well for initial setup, such as positioning elements or setting their dimensions when first creating your UI. However, when you need visual elements to respond to user interactions (like highlighting a button when it is focused), a different approach is required. + +For these dynamic changes, Gum uses a system of **states** (implemented as `StateSave` objects), each Forms control maintains a collection of named states that are automatically applied in response to specific user interactions. When a button becomes focused, for instance, Gum looks for an applies a state named "Focused" to alter its appearance. + +> [!NOTE] +> In the next chapter during the customization pass, we will create states to visually indicate when controls are focused, providing clear feedback to the player. + +## Updating Our Game To Use Gum + +Now that we have covered the core UI concepts and how Gum will help implement them, we can integrate Gum into our game project. We will add the framework, initialize it, and prepare it for use in our scenes. + +For now we will use the default styling in Gum to quickly iterate and build the UI and do a customization styling pass in the next chapter. + +### Adding the Gum NuGet Package + +Before we can use Gum in our project, we first need to add it using NuGet. NuGet is a package manager for .NET projects that allows you to add third-party libraries into your project, similar to how we [created and added our own class library](../04_creating_a_class_library/index.md). + +To add the Gum NuGet package to our game project, follow the instructions below based on your development environment: + +#### [Visual Studio Code](#tab/vscode) + +To add the Gum NuGet package in Visual Studio Code: + +1. In the [*Solution Explorer*](../02_getting_started/index.md#install-the-c-dev-kit-extension) panel, right-click the `DungeonSlime` project. +2. Choose `Add NuGet Package` from the context menu. +3. Enter `Gum.MonoGame` in the `Add NuGet Package` search prompt and press Enter. +4. When the search finishes, select the `Gum.MonoGame` package in the results +5. When prompted for a version choose version `2025.5.1.1`. + +#### [Visual Studio 2022](#tab/vs2022) + +To Add the Gum NuGet package in Visual Studio 2022: + +1. In the *Solution Explorer* panel, right-click the *DungeonSlime* project. +2. Choose `Manage Nuget Packages...` from the context menu. +3. In the NuGet Package Manager window, select the `Browse` tab if it is not already selected. +4. In the search box, enter `Gum.MonoGame`. +5. Select the "Gum.MonoGame" package from the search results. +6. On the right, in the version dropdown, select version `2025.5.1.1` and click the "Install" button. + +#### [dotnet CLI](#tab/dotnetcli) + +To add the Gum NuGet package using the dotnet CLI: + +1. Open a Command Prompt or Terminal window in the same folder as the `DungeonSlime.csproj` project file. +2. Enter the following command: + + ```sh + dotnet add DungeonSlime.csproj package Gum.MonoGame --version 2025.5.1.1 + ``` + +--- + +> [!TIP] +> You can verify the package was successfully added by examining your `DungeonSlime.csproj` file, which should now contain a reference like: +> +> ```xml +> +> ``` + +> [!IMPORTANT] +> This tutorial uses version `2025.5.5.1` of Gum, which is the latest version of Gum as of this writing. That exact version is specified to use in the section above when installing the NuGet package to ensure compatibility throughout this tutorial. If there are newer versions of Gum available, please consult the [Gum documentation](https://docs.flatredball.com/gum/gum-tool/breaking-changes) before updating in case there are any breaking changes from the code that is presented in this tutorial. + +### Adding UI Sound Effect + +To make our UI more responsive and engaging, we will add audio feedback that plays when players interact with buttons and other UI elements. Sound effects provide immediate confirmation that an input has been recognized, creating a more engaging experience. + +First, download the UI sound effect by right-clicking the following link and saving it as `ui.wav` in the game project's `Content/audio` folder: + +* [ui.wav](./files/ui.wav){download} + +Next, add this sound effect to your content project using the MGCB Editor: + +1. Open the `Content.mgcb` content project file in the MGCB Editor. +2. Right-click the `audio` folder and choose `Add > Existing Item...`. +3. Navigate to and select the `ui.wav` file you just downloaded. +4. In the Properties panel, verify that the `Processor` is set to `Sound Effect`. +5. Save the changes and close the MGCB Editor. + +| ![Figure 20-1: The MGCB Editor with ui.wav added to the audio folder](./images/mgcb-editor.png) | +| :---------------------------------------------------------------------------------------------: | +| **Figure 20-1: The MGCB Editor with ui.wav added to the audio folder** | + +We will load and use this sound effect in our UI implementation to provide auditory feedback when players interact with buttons and sliders. + +### Initializing Gum + +With the Gum NuGet package added to our project, we need to initialize Gum in our game, this will enable the UI system and configure input handling for our controls. Since this is an initialization that only needs to happen once, we can make the necessary changes to the `Game1` class. + +First, open the `Game1.cs` file and add the following new using statements to the top: + +[!code-csharp[](./snippets/game1/usings.cs?highlight=4-5)] + +Next, add the following method to the `Game1` class to encapsulate the initializations of the Gum UI service: + +[!code-csharp[](./snippets/game1/initializegum.cs)] + +Finally, update the [**Initialize**](xref:Microsoft.Xna.Framework.Game.Initialize) method to call the `InitializeGum` method we just created: + +[!code-csharp[](./snippets/game1/initialize.cs?highlight=8-9)] + +The following is a breakdown of this initialization process: + +1. **Basic Initialization**: `GumService.Default.Initialize(this)` sets up the Gum system with our game instance. This is required for any gum project. + + > [!NOTE] + > We only need to pass our [**Game**](xref:Microsoft.Xna.Framework.Game) instance since we are using Gum as a code-first approach. Gum also offers a visual editor that creates Gum project files. When using the editor, you will need to also pass the Gum Project file here. + +2. **Content Loading**: Gum needs to be made aware of which content manager to use to load assets through the content pipeline. By setting `GumService.Default.ContentLoader.XnaContentManager = Core.Content`, we tell Gum to use our game's content manager when loading assets. By using the game's existing content manager, Gum also gets the benefit of the caching that the content manager performs when loading assets. +3. **Input Configuration**: + * By default, all Forms controls automatically respond to mouse and touch screen input devices. We need to explicitly register keyboard and gamepad input devices by using th `FrameworkElement.KeyboardsForUiControl` and `Framework.GamePadsForUiControl` properties. + * By default, Forms controls will automatically respond to tab and shift-tab for navigation. By using the `FrameworkElement.TabKeyCombos` and `FrameworkElement.TabReverseKeyCombos` properties, we can add additional key combinations for tabbing. Here map the Up arrow for reverse tabbing and the Down arrow for forward tabbing. + + > [!TIP] + > If you prefer different navigation keys, you can remove the built-in Tab/Shift+Tab navigation. + > + > Simply call these methods before adding your custom combinations: + > + > ```cs + > FrameworkElement.TabKeyCombos.Clear(); + > FrameworkElement.TabReverseKeyCombos.Clear(); + > ``` + +4. **UI Scaling**: Gum allows us to independently scale the UI regardless of the resolution of the game. Our game is set to a resolution of 1280x720, however as we will see during the styling section later, the UI assets created were done at one-fourth the size to reduce the size of the texture atlas. Here, we use the `GumService.Default.CanvasWidth` and `GumService.Default.CanvasHeight` properties to set the canvas size to one-fourth that of our game's resolution. Then using by setting the `GumService.Default.Renderer.Camera.Zoom` property to four, we effectively make it render the UI at full resolution. + +Gum is now fully initialized and we can use it in our scenes to add UI to our game. + +### Adding TitleScene UI + +With Gum added and initialized in our game, we can now implement UI elements for our title scene. We will create panels for both the main menu and options menu, implement the necessary event handlers, and integrate everything with our existing title scene. + +> [!NOTE] +> When adding these sections one by one, you may see compiler errors until all sections are in place. This is normal, as some parts of the code will reference fields or methods that haven't been added yet. Once all sections are complete, these errors will resolve. + +First, open the *TitleScene.cs* file in the game project and add the following using declarations to the top of the `TitleScene` class: + +[!code-csharp[](./snippets/titlescene/usings.cs?highlight=1,3,6-8)] + +Next, add the following fields to the `TitleScene` class: + +[!code-csharp[](./snippets/titlescene/fields.cs)] + +#### Creating the Title Panel + +First, create a new method that builds our main menu panel with start and options buttons. + +Add the following method to the `TitleScene` class: + +[!code-csharp[](./snippets/titlescene/createtitlepanel.cs)] + +Our title panel includes two buttons positioned at the bottom corners of the screen. The "Start" button will allow players to begin the game while the "Options" button will hide the main menu and display the options menu. + +> [!NOTE] +> Notice how we use `Anchor` to position the buttons relative to the panel's edges, with the "Start" button anchored at the bottom left and the "Options" button anchored at the bottom right. Then the positioning of the elements is adjusted relative to its anchor point. + +Each button registers a `Click` event handler to respond when the players selects it, we should implement the event handler method for these buttons next. First we will implement the handler for the "Start" button. Add the following method to the `TitleScene` class after the `CreateTitlePanel` method: + +[!code-csharp[](./snippets/titlescene/handlestartclicked.cs)] + +When the "Start" button is clicked and this method is called, it will play the UI sound effect for auditory feedback then change the scene tot he game scene so the player can start playing the game. + +Next is the handler for the "Options" button. Add the following method to the `TitleScene` class after the `HandleStartClicked` method: + +[!code-csharp[](./snippets/titlescene/handleoptionsclicked.cs)] + +When the "Options" button is clicked and this method is called, it will play the UI sound effect for auditory feedback then hide the title panel and show the options panel. + +#### Creating the Options Panel + +Next, we will create the options panel with sliders to adjust the volume for music and sound effects. + +Add the following method to the `TitleScene` class: + +[!code-csharp[](./snippets/titlescene/createoptionspanel.cs)] + +This panel includes a text label, two sliders for adjusting audio volumes, and a back button for returning to the main menu. The panel is initially invisible since we start on the main menu. Both the "Music Volume" slider and the "Sound Effects Volume" slider register events to be called when the value of the sliders change and when the value change has been completed. The "Back" button registers a click event similar to the ones from the main menu. + +Now we should implement the event handlers for these controls. First, we will implement the handler for when the value of the sound effect volume slider changes. Add the following method to the `TitleScene` class after the `CreateOptionsPanel` method: + +[!code-csharp[](./snippets/titlescene/handlesfxsliderchanged.cs)] + +When the value of the "Sound Effects Volume" slider changes and this method is called, a reference to the slider is captured and then the the global sound effect volume is adjusted based on the value of the slider. + +Next is the handler when the "Sound Effects Volume" slider has completed a value change. Add the following method to the `TitleScene` class after the `HandleSfxSliderChanged` method: + +[!code-csharp[](./snippets/titlescene/handlesfxsliderchangecompleted.cs)] + +When the value of the "Sound Effects Volume" slider has completed a change and this method is called, it plays the UI sound effect to provide auditory feedback so the player can hear the difference in volume. + +After this is the handler for when the "Music Volume" slider value changes. Add the following method to the `TitleScene` class after the `HandleSfxSliderChangeCompleted` method: + +[!code-csharp[](./snippets/titlescene/handlemusicslidervaluechanged.cs)] + +Similar to how we handled the "Sound Effect Volume" slider value changes, when the "Music Volume" slider value changes and this method is called, a reference to the slider is captured and then the global music volume is adjusted based on the value of the slider. + +Next is the handler when the "Music Volume" slider value has completed a value change. Add the following method to the `TitleScene` class after the `HandleMusicSliderValueChanged` method: + +[!code-csharp[](./snippets/titlescene/handlemusicslidervaluechangecompleted.cs)] + +When the value of the "Music Volume" slider has completed a change, the UI sound effect is played to provide auditory feedback. + +Finally, we need to add the handler for when the "Back" button is clicked on the options panel. Add the following method to the `TitleScene` class after the `HandleMusicSliderValueChangeCompleted` method: + +[!code-csharp[](./snippets/titlescene/handleoptionsbuttonback.cs)] + +This method plays the UI sound effect for auditory feedback, then hides the options panel and shows the title panel. + +> [!TIP] +> Notice that for both sliders, we registered a method for the `ValueChangeCompleted` event. This is so we can play the UI sound effect only when the player has finished adjusting the slider value. If we had instead played the UI sound effect in the `ValueChanged` event, then the UI sound effect would trigger constantly while the slider is being adjusted if using a mouse to drag it. + +#### Initializing the UI + +Now that we have implemented the methods that will create both the main menu panel and the options menu panel, we need to implement the main UI initializations method that will call them. Add the following method to the `TitleScene` class after the `HandleOptionsButtonBack` method: + +[!code-csharp[](./snippets/titlescene/initializeui.cs)] + +This method first clears any existing UI elements from Gum's root container to prevent duplication, then calls our panel creation methods to build the complete interface. + +#### Integrating with the Game Loop + +Finally, we need to integrate our UI initialization, update, and draw with the scene's lifecycle. First, add the call to `InitializeUI()` in the `Initialize` method by updating it to the following: + +[!code[](./snippets/titlescene/initialize.cs?highlight=27)] + +Next, update the `LoadContent` method to load the sound effect that will be used as auditory feedback for the UI: + +[!code[](./snippets/titlescene/loadcontent.cs?highlight=12-13)] + +Next modify the `Update` method to include Gum's update logic: + +[!code[](./snippets/titlescene/update.cs?highlight=14)] + +Finally, the `Draw` method needs to be updated to: + +1. Only show the text for the game title when the title panel is visible +2. Add Gum's drawing call to draw the user interface + +Update the `Draw` method to the following: + +[!code[](./snippets/titlescene/draw.cs?highlight=10-34,36)] + +With these changes, our UI system is now fully integrated into the scene's game loop. Gum updates its controls in the `Update` method and draws them in the `Draw` method. This produces a fully functional title screen with buttons that allows players to start the game or adjust audio settings. + +| ![Figure 20-2: Title screen with default Gum buttons](./images/title-unstyled.png) | +| :--------------------------------------------------------------------------------: | +| **Figure 20-2: Title screen with default Gum buttons** | + +> [!NOTE] +> You may notice that the UI elements currently use Gum's default styling, which does not match our game's visual theme. We will explore customizing these controls to match our game's visual style in the next chapter. + +### Adding GameScene UI + +Now that we have setup the UI for the title scene, we will add a pause menu to our game scene. This UI will start invisible but will be shown when the player presses the escape key. For consistency, we will implement the UI for the game scene in the same order that we implemented the UI for the title scene. + +> [!NOTE] +> When adding these sections one by one, you may see compiler errors until all sections are in place. This is normal, as some parts of the code will reference fields or methods that haven't been added yet. Once all sections are complete, these errors will resolve. + +First, open the *GameScene.cs* file in the game project and add the following using declarations to the top of the `GameScene` class. + +[!code-csharp[](./snippets/gamescene/usings.cs?highlight=2-3,8-10)] + +Next, add the following fields to the `GameScene` class: + +[!code-csharp[](./snippets/gamescene/fields.cs)] + +#### Pausing the Game + +To pause the game, first we will create a method that makes the pause panel visible. Add the following method to the `GameScene` class after the `CheckGamePadInput` method: + +[!code-csharp[](./snippets/gamescene/pausegame.cs)] + +Next, update the `CheckKeyboardInput` method so that when the escape key is pressed, instead of returning to the title scene, we now pause the game: + +[!code-csharp[](./snippets/gamescene/checkkeyboardinput.cs?highlight=6-10)] + +Finally, update the `CheckGamePadInput` method so that when the start button is pressed, it pauses the game: + +[!code-csharp[](./snippets/gamescene/checkgamepadinput.cs?highlight=6-10)] + +#### Creating the Pause Panel + +Next, we will create a method that builds our pause panel with resume and quit buttons. Add the following method to the `GameScene` class after the `LoadContent` method: + +[!code-csharp[](./snippets/gamescene/createpausepanel.cs)] + +Now we should implement the event handlers for these controls. First, we will implement the handler for the "Resume" button. Add the following method to the `GameScene` class after the `CreatePausePanel` method: + +[!code-csharp[](./snippets/gamescene/handleresumebuttonclicked.cs)] + +This method plays the UI sound effect for auditory feedback and then hides the pause panel so that the game can resume. + +Next is the handler for the "Quit" button. Add the following method to the `GameScene` class after the `HandleResumeButtonClicked` method: + +[!code-csharp[](./snippets/gamescene/handlequitbuttonclicked.cs)] + +This method as well plays the UI sound effect for auditory feedback, then quits the game by changing scenes back to the title scene. + +#### Initializing the Game UI + +Now that we have implemented the method to create the pause panel, we can implement the main UI initializations method that will call them. Add the following method to the `GameScene` class after the `HandleQuitButtonClicked` method: + +[!code-csharp[](./snippets/gamescene/initializeui.cs)] + +Just like with the `TitleScene`, we first clear any existing UI elements from Gum's root before creating the UI elements for this scene. + +#### Integrating with the Game Loop for the GameScreen + +Finally, we need to integrate our UI initialization, update, and draw with the scene's lifecycle. First add the call to `InitializeUI()` in the `Initialize` method by updating it to the following: + +[!code-csharp[](./snippets/gamescene/initialize.cs?highlight=38)] + +Next, update the `LoadContent` method to load the sound effect that will be used as auditory feedback for the UI: + +[!code-csharp[](./snippets/gamescene/loadcontent.cs?highlight=27-28)] + +Next, modify the `Update` method to include Gum's update logic and to only update the game if it is not paused. We will use the visibility of the pause menu to determine if the game is paused or not: + +[!code-csharp[](./snippets/gamescene/update.cs?highlight=3-10)] + +Finally, add Gum's drawing call to the end fo the `Draw` method: + +[!code-csharp[](./snippets/gamescene/draw.cs?highlight=9-10)] + +With these changes, the pause menu is now fully integrated into the game scene's game loop. Gum updates its controls during the `Update` method and draws them during the `Draw` method. If the game is paused, as determined by the `IsVisible` property of the pause menu, then updating the actual game logic is skipped. + +| ![Figure 20-3: The pause menu during the game scene with default Gum buttons](./images/pause-unstyled.png) | +| :---------------------------------------------------------------------------------------------------------: | +| **Figure 20-3: The pause menu during the game scene with default Gum buttons** | + +## Conclusion + +In this chapter, you accomplished the following: + +* Add and configure the Gum NuGet package in your project. +* Understand key Gum concepts like Forms controls and Visuals. +* Create and position UI elements using anchoring and docking. +* Implement interactive controls like buttons and sliders. +* Handle user input from various input devices. +* Create transitions between different UI screens. +* Integrate the UI system with the game's scene architecture. + +While this UI is now functional, you may have noticed that it uses Gum's default styling which does not match our game's visual theme. In the next chapter, we will learn how to customize the appearance of our UI elements to create a cohesive visual style that complements our game's aesthetic. + +## Test Your Knowledge + +1. What are the two main types of objects in Gum, and how do they differ? + + :::question-answer + The two main types are: + + * **Forms**: Interactive UI elements like buttons, sliders, and panels that handle user input. They provide built-in functionality for common UI interactions. + * **Visuals**: Display elements like TextRuntime, ColoredRectangleRuntime, and NineSliceRuntime that are used to render graphics. They have no built-in interaction behavior but can be customized visually. + + Forms controls contain Visuals, accessible through the `Visual` property, creating a separation between functionality and presentation. + ::: + +2. How does Gum handle the parent-child relationship of UI elements, and why is this important? + + :::question-answer + Gum implements parent-child relationships through a hierarchical structure where: + + * UI elements must be connected to the root container to be visible + * Children can be added directly to a parent's Visual.Children collection + * Position coordinates of child elements are relative to their parent + * Property changes like visibility cascade from parent to children + + This relationship is important because it allows for organizing related UI elements as groups, controlling entire sections of UI with a single property change, and positioning elements relative to their container rather than absolute screen coordinates. + ::: + +3. What are the two ways to customize the appearance of Gum UI elements? + + :::question-answer + The two ways to customize Gum UI elements are: + + 1. **Direct property assignment**: Setting properties directly in code (like `MyButton.Visual.Width = 100`). This works well for initial setup and static properties. + 2. **States**: Using Gum's state system (`StateSave` objects) to define different visual states that can be applied in response to specific conditions or events. States are automatically applied by Forms controls in response to user interactions (like focus or highlighting). + + States are useful for dynamic changes that occur during gameplay, as they separate visual response logic from game logic. + ::: + +4. What steps are necessary to integrate Gum's UI system with MonoGame's game loop? + + :::question-answer + To integrate Gum with MonoGame's game loop: + + 1. Initialize Gum in the game's Initialize method with `GumService.Default.Initialize(this)` + 2. Configure content loading by setting `GumService.Default.ContentLoader.XnaContentManager` + 3. Set up input handling by adding keyboards and gamepads to `FrameworkElement.KeyboardsForUiControl` and `FrameworkElement.GamePadsForUiControl` + 4. Call `GumService.Default.Update()` in the game's Update method + 5. Call `GumService.Default.Draw()` in the game's Draw method + 6. For scene transitions, clear existing UI elements with `GumService.Default.Root.Children.Clear()` + + This ensures Gum can update and render UI elements in sync with the game's main loop. + ::: diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/game1/initialize.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/game1/initialize.cs new file mode 100644 index 00000000..f12e23ca --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/game1/initialize.cs @@ -0,0 +1,13 @@ +protected override void Initialize() +{ + base.Initialize(); + + // Start playing the background music + Audio.PlaySong(_themeSong); + + // Initialize the Gum UI service + InitializeGum(); + + // Start the game with the title scene. + ChangeScene(new TitleScene()); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/game1/initializegum.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/game1/initializegum.cs new file mode 100644 index 00000000..53a7cca5 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/game1/initializegum.cs @@ -0,0 +1,32 @@ +private void InitializeGum() +{ + // Initialize the Gum service + GumService.Default.Initialize(this); + + // Tell the Gum service which content manager to use. We will tell it to + // use the global content manager from our Core. + GumService.Default.ContentLoader.XnaContentManager = Core.Content; + + // Register keyboard input for UI control. + FrameworkElement.KeyboardsForUiControl.Add(GumService.Default.Keyboard); + + // Register gamepad input for Ui control. + FrameworkElement.GamePadsForUiControl.AddRange(GumService.Default.Gamepads); + + // Customize the tab reverse UI navigation to also trigger when the keyboard + // Up arrow key is pushed. + FrameworkElement.TabReverseKeyCombos.Add( + new KeyCombo() { PushedKey = Microsoft.Xna.Framework.Input.Keys.Up }); + + // Customize the tab UI navigation to also trigger when the keyboard + // Down arrow key is pushed. + FrameworkElement.TabKeyCombos.Add( + new KeyCombo() { PushedKey = Microsoft.Xna.Framework.Input.Keys.Down }); + + // The assets created for the UI were done so at 1/4th the size to keep the size of the + // texture atlas small. So we will set the default canvas size to be 1/4th the size of + // the game's resolution then tell gum to zoom in by a factor of 4. + GumService.Default.CanvasWidth = GraphicsDevice.PresentationParameters.BackBufferWidth / 4.0f; + GumService.Default.CanvasHeight = GraphicsDevice.PresentationParameters.BackBufferHeight / 4.0f; + GumService.Default.Renderer.Camera.Zoom = 4.0f; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/game1/usings.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/game1/usings.cs new file mode 100644 index 00000000..6b518e38 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/game1/usings.cs @@ -0,0 +1,5 @@ +using DungeonSlime.Scenes; +using Microsoft.Xna.Framework.Media; +using MonoGameLibrary; +using MonoGameGum; +using MonoGameGum.Forms.Controls; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/checkgamepadinput.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/checkgamepadinput.cs new file mode 100644 index 00000000..0654bb9e --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/checkgamepadinput.cs @@ -0,0 +1,13 @@ +private void CheckGamePadInput() +{ + // Get the gamepad info for gamepad one. + GamePadInfo gamePadOne = Core.Input.GamePads[(int)PlayerIndex.One]; + + // If the start button is pressed, pause the game + if (gamePadOne.WasButtonJustPressed(Buttons.Start)) + { + PauseGame(); + } + + // Existing gamepad input code + // ... \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/checkkeyboardinput.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/checkkeyboardinput.cs new file mode 100644 index 00000000..9952b252 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/checkkeyboardinput.cs @@ -0,0 +1,13 @@ + private void CheckKeyboardInput() + { + // Get a reference to the keyboard info + KeyboardInfo keyboard = Core.Input.Keyboard; + + // If the escape key is pressed, pause the game. + if (Core.Input.Keyboard.WasKeyJustPressed(Keys.Escape)) + { + PauseGame(); + } + + // Existing keyboard input code + // ... \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/createpausepanel.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/createpausepanel.cs new file mode 100644 index 00000000..914ff97a --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/createpausepanel.cs @@ -0,0 +1,41 @@ +private void CreatePausePanel() +{ + _pausePanel = new Panel(); + _pausePanel.Anchor(Anchor.Center); + _pausePanel.Visual.WidthUnits = DimensionUnitType.Absolute; + _pausePanel.Visual.HeightUnits = DimensionUnitType.Absolute; + _pausePanel.Visual.Height = 70; + _pausePanel.Visual.Width = 264; + _pausePanel.IsVisible = false; + _pausePanel.AddToRoot(); + + var background = new ColoredRectangleRuntime(); + background.Dock(Dock.Fill); + background.Color = Color.DarkBlue; + _pausePanel.AddChild(background); + + var textInstance = new TextRuntime(); + textInstance.Text = "PAUSED"; + textInstance.X = 10f; + textInstance.Y = 10f; + _pausePanel.AddChild(textInstance); + + _resumeButton = new Button(); + _resumeButton.Text = "RESUME"; + _resumeButton.Anchor(Anchor.BottomLeft); + _resumeButton.Visual.X = 9f; + _resumeButton.Visual.Y = -9f; + _resumeButton.Visual.Width = 80; + _resumeButton.Click += HandleResumeButtonClicked; + _pausePanel.AddChild(_resumeButton); + + var quitButton = new Button(); + quitButton.Text = "QUIT"; + quitButton.Anchor(Anchor.BottomRight); + quitButton.Visual.X = -9f; + quitButton.Visual.Y = -9f; + quitButton.Width = 80; + quitButton.Click += HandleQuitButtonClicked; + + _pausePanel.AddChild(quitButton); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/draw.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/draw.cs new file mode 100644 index 00000000..e27645a3 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/draw.cs @@ -0,0 +1,11 @@ +public override void Draw(GameTime gameTime) +{ + // Existing game draw code + // ... + + // Always end the sprite batch when finished + Core.SpriteBatch.End(); + + // Draw the Gum UI + GumService.Default.Draw(); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/fields.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/fields.cs new file mode 100644 index 00000000..da461fdc --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/fields.cs @@ -0,0 +1,10 @@ +// A reference to the pause panel UI element so we can set its visibility +// when the game is paused. +private Panel _pausePanel; + +// A reference to the resume button UI element so we can focus it +// when the game is paused. +private Button _resumeButton; + +// The UI sound effect to play when a UI event is triggered. +private SoundEffect _uiSoundEffect; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/handlequitbuttonclicked.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/handlequitbuttonclicked.cs new file mode 100644 index 00000000..7ab07c21 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/handlequitbuttonclicked.cs @@ -0,0 +1,8 @@ +private void HandleQuitButtonClicked(object sender, EventArgs e) +{ + // A UI interaction occurred, play the sound effect + Core.Audio.PlaySoundEffect(_uiSoundEffect); + + // Go back to the title scene. + Core.ChangeScene(new TitleScene()); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/handleresumebuttonclicked.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/handleresumebuttonclicked.cs new file mode 100644 index 00000000..b840511e --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/handleresumebuttonclicked.cs @@ -0,0 +1,8 @@ +private void HandleResumeButtonClicked(object sender, EventArgs e) +{ + // A UI interaction occurred, play the sound effect + Core.Audio.PlaySoundEffect(_uiSoundEffect); + + // Make the pause panel invisible to resume the game. + _pausePanel.IsVisible = false; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/initialize.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/initialize.cs new file mode 100644 index 00000000..3225701a --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/initialize.cs @@ -0,0 +1,39 @@ +public override void Initialize() +{ + // LoadContent is called during base.Initialize(). + base.Initialize(); + + // During the game scene, we want to disable exit on escape. Instead, + // the escape key will be used to return back to the title screen + Core.ExitOnEscape = false; + + Rectangle screenBounds = Core.GraphicsDevice.PresentationParameters.Bounds; + + _roomBounds = new Rectangle( + (int)_tilemap.TileWidth, + (int)_tilemap.TileHeight, + screenBounds.Width - (int)_tilemap.TileWidth * 2, + screenBounds.Height - (int)_tilemap.TileHeight * 2 + ); + + // Initial slime position will be the center tile of the tile map. + int centerRow = _tilemap.Rows / 2; + int centerColumn = _tilemap.Columns / 2; + _slimePosition = new Vector2(centerColumn * _tilemap.TileWidth, centerRow * _tilemap.TileHeight); + + // Initial bat position will the in the top left corner of the room + _batPosition = new Vector2(_roomBounds.Left, _roomBounds.Top); + + // Set the position of the score text to align to the left edge of the + // room bounds, and to vertically be at the center of the first tile. + _scoreTextPosition = new Vector2(_roomBounds.Left, _tilemap.TileHeight * 0.5f); + + // Set the origin of the text so it is left-centered. + float scoreTextYOrigin = _font.MeasureString("Score").Y * 0.5f; + _scoreTextOrigin = new Vector2(0, scoreTextYOrigin); + + // Assign the initial random velocity to the bat. + AssignRandomBatVelocity(); + + InitializeUI(); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/initializeui.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/initializeui.cs new file mode 100644 index 00000000..ad704c03 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/initializeui.cs @@ -0,0 +1,6 @@ +private void InitializeUI() +{ + GumService.Default.Root.Children.Clear(); + + CreatePausePanel(); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/loadcontent.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/loadcontent.cs new file mode 100644 index 00000000..1b681aef --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/loadcontent.cs @@ -0,0 +1,29 @@ +public override void LoadContent() +{ + // Create the texture atlas from the XML configuration file + TextureAtlas atlas = TextureAtlas.FromFile(Core.Content, "images/atlas-definition.xml"); + + // Create the slime animated sprite from the atlas. + _slime = atlas.CreateAnimatedSprite("slime-animation"); + _slime.Scale = new Vector2(4.0f, 4.0f); + + // Create the bat animated sprite from the atlas. + _bat = atlas.CreateAnimatedSprite("bat-animation"); + _bat.Scale = new Vector2(4.0f, 4.0f); + + // Create the tilemap from the XML configuration file. + _tilemap = Tilemap.FromFile(Content, "images/tilemap-definition.xml"); + _tilemap.Scale = new Vector2(4.0f, 4.0f); + + // Load the bounce sound effect + _bounceSoundEffect = Content.Load("audio/bounce"); + + // Load the collect sound effect + _collectSoundEffect = Content.Load("audio/collect"); + + // Load the font + _font = Core.Content.Load("fonts/04B_30"); + + // Load the sound effect to play when ui actions occur. + _uiSoundEffect = Core.Content.Load("audio/ui"); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/pausegame.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/pausegame.cs new file mode 100644 index 00000000..a772e59a --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/pausegame.cs @@ -0,0 +1,8 @@ +private void PauseGame() +{ + // Make the pause panel UI element visible. + _pausePanel.IsVisible = true; + + // Set the resume button to have focus + _resumeButton.IsFocused = true; +} diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/update.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/update.cs new file mode 100644 index 00000000..0c7b5296 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/update.cs @@ -0,0 +1,13 @@ + public override void Update(GameTime gameTime) + { + // Ensure the UI is always updated + GumService.Default.Update(gameTime); + + // If the game is paused, do not continue + if (_pausePanel.IsVisible) + { + return; + } + + // Existing game update code + // ... \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/usings.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/usings.cs new file mode 100644 index 00000000..1ed2b803 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/gamescene/usings.cs @@ -0,0 +1,14 @@ +using System; +using Gum.DataTypes; +using Gum.Wireframe; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameGum; +using MonoGameGum.Forms.Controls; +using MonoGameGum.GueDeriving; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; +using MonoGameLibrary.Input; +using MonoGameLibrary.Scenes; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/createoptionspanel.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/createoptionspanel.cs new file mode 100644 index 00000000..e88fbc8c --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/createoptionspanel.cs @@ -0,0 +1,45 @@ +private void CreateOptionsPanel() +{ + _optionsPanel = new Panel(); + _optionsPanel.Dock(Gum.Wireframe.Dock.Fill); + _optionsPanel.IsVisible = false; + _optionsPanel.AddToRoot(); + + var optionsText = new TextRuntime(); + optionsText.X = 10; + optionsText.Y = 10; + optionsText.Text = "OPTIONS"; + _optionsPanel.AddChild(optionsText); + + var musicSlider = new Slider(); + musicSlider.Anchor(Gum.Wireframe.Anchor.Top); + musicSlider.Visual.Y = 30f; + musicSlider.Minimum = 0; + musicSlider.Maximum = 1; + musicSlider.Value = Core.Audio.SongVolume; + musicSlider.SmallChange = .1; + musicSlider.LargeChange = .2; + musicSlider.ValueChanged += HandleMusicSliderValueChanged; + musicSlider.ValueChangeCompleted += HandleMusicSliderValueChangeCompleted; + _optionsPanel.AddChild(musicSlider); + + var sfxSlider = new Slider(); + sfxSlider.Anchor(Gum.Wireframe.Anchor.Top); + sfxSlider.Visual.Y = 93; + sfxSlider.Minimum = 0; + sfxSlider.Maximum = 1; + sfxSlider.Value = Core.Audio.SoundEffectVolume; + sfxSlider.SmallChange = .1; + sfxSlider.LargeChange = .2; + sfxSlider.ValueChanged += HandleSfxSliderChanged; + sfxSlider.ValueChangeCompleted += HandleSfxSliderChangeCompleted; + _optionsPanel.AddChild(sfxSlider); + + _optionsBackButton = new Button(); + _optionsBackButton.Text = "BACK"; + _optionsBackButton.Anchor(Gum.Wireframe.Anchor.BottomRight); + _optionsBackButton.X = -28f; + _optionsBackButton.Y = -10f; + _optionsBackButton.Click += HandleOptionsButtonBack; + _optionsPanel.AddChild(_optionsBackButton); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/createtitlepanel.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/createtitlepanel.cs new file mode 100644 index 00000000..45b26b3e --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/createtitlepanel.cs @@ -0,0 +1,27 @@ +private void CreateTitlePanel() +{ + // Create a container to hold all of our buttons + _titleScreenButtonsPanel = new Panel(); + _titleScreenButtonsPanel.Dock(Gum.Wireframe.Dock.Fill); + _titleScreenButtonsPanel.AddToRoot(); + + var startButton = new Button(); + startButton.Anchor(Gum.Wireframe.Anchor.BottomLeft); + startButton.Visual.X = 50; + startButton.Visual.Y = -12; + startButton.Visual.Width = 70; + startButton.Text = "Start"; + startButton.Click += HandleStartClicked; + _titleScreenButtonsPanel.AddChild(startButton); + + _optionsButton = new Button(); + _optionsButton.Anchor(Gum.Wireframe.Anchor.BottomRight); + _optionsButton.Visual.X = -50; + _optionsButton.Visual.Y = -12; + _optionsButton.Visual.Width = 70; + _optionsButton.Text = "Options"; + _optionsButton.Click += HandleOptionsClicked; + _titleScreenButtonsPanel.AddChild(_optionsButton); + + startButton.IsFocused = true; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/draw.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/draw.cs new file mode 100644 index 00000000..4c80d16c --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/draw.cs @@ -0,0 +1,37 @@ +public override void Draw(GameTime gameTime) +{ + Core.GraphicsDevice.Clear(new Color(32, 40, 78, 255)); + + // Draw the background pattern first using the PointWrap sampler state. + Core.SpriteBatch.Begin(samplerState: SamplerState.PointWrap); + Core.SpriteBatch.Draw(_backgroundPattern, _backgroundDestination, new Rectangle(_backgroundOffset.ToPoint(), _backgroundDestination.Size), Color.White * 0.5f); + Core.SpriteBatch.End(); + + if (_titleScreenButtonsPanel.IsVisible) + { + // Begin the sprite batch to prepare for rendering. + Core.SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + // The color to use for the drop shadow text. + Color dropShadowColor = Color.Black * 0.5f; + + // Draw the Dungeon text slightly offset from it is original position and + // with a transparent color to give it a drop shadow + Core.SpriteBatch.DrawString(_font5x, DUNGEON_TEXT, _dungeonTextPos + new Vector2(10, 10), dropShadowColor, 0.0f, _dungeonTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + + // Draw the Dungeon text on top of that at its original position + Core.SpriteBatch.DrawString(_font5x, DUNGEON_TEXT, _dungeonTextPos, Color.White, 0.0f, _dungeonTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + + // Draw the Slime text slightly offset from it is original position and + // with a transparent color to give it a drop shadow + Core.SpriteBatch.DrawString(_font5x, SLIME_TEXT, _slimeTextPos + new Vector2(10, 10), dropShadowColor, 0.0f, _slimeTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + + // Draw the Slime text on top of that at its original position + Core.SpriteBatch.DrawString(_font5x, SLIME_TEXT, _slimeTextPos, Color.White, 0.0f, _slimeTextOrigin, 1.0f, SpriteEffects.None, 1.0f); + + // Always end the sprite batch when finished. + Core.SpriteBatch.End(); + } + + GumService.Default.Draw(); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/fields.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/fields.cs new file mode 100644 index 00000000..db6a8697 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/fields.cs @@ -0,0 +1,5 @@ +private SoundEffect _uiSoundEffect; +private Panel _titleScreenButtonsPanel; +private Panel _optionsPanel; +private Button _optionsButton; +private Button _optionsBackButton; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handlemusicslidervaluechangecompleted.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handlemusicslidervaluechangecompleted.cs new file mode 100644 index 00000000..09a5c3d5 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handlemusicslidervaluechangecompleted.cs @@ -0,0 +1,5 @@ +private void HandleMusicSliderValueChangeCompleted(object sender, EventArgs args) +{ + // A UI interaction occurred, play the sound effect + Core.Audio.PlaySoundEffect(_uiSoundEffect); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handlemusicslidervaluechanged.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handlemusicslidervaluechanged.cs new file mode 100644 index 00000000..6b1f3143 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handlemusicslidervaluechanged.cs @@ -0,0 +1,12 @@ +private void HandleMusicSliderValueChanged(object sender, EventArgs args) +{ + // Intentionally not playing the UI sound effect here so that it is not + // constantly triggered as the user adjusts the slider's thumb on the + // track. + + // Get a reference to the sender as a Slider. + var slider = (Slider)sender; + + // Set the global song volume to the value of the slider. + Core.Audio.SongVolume = (float)slider.Value; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handleoptionsbuttonback.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handleoptionsbuttonback.cs new file mode 100644 index 00000000..f8a1d01a --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handleoptionsbuttonback.cs @@ -0,0 +1,15 @@ +private void HandleOptionsButtonBack(object sender, EventArgs e) +{ + // A UI interaction occurred, play the sound effect + Core.Audio.PlaySoundEffect(_uiSoundEffect); + + // Set the title panel to be visible. + _titleScreenButtonsPanel.IsVisible = true; + + // Set the options panel to be invisible. + _optionsPanel.IsVisible = false; + + // Give the options button on the title panel focus since we are coming + // back from the options screen. + _optionsButton.IsFocused = true; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handleoptionsclicked.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handleoptionsclicked.cs new file mode 100644 index 00000000..3ace9d29 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handleoptionsclicked.cs @@ -0,0 +1,14 @@ +private void HandleOptionsClicked(object sender, EventArgs e) +{ + // A UI interaction occurred, play the sound effect + Core.Audio.PlaySoundEffect(_uiSoundEffect); + + // Set the title panel to be invisible. + _titleScreenButtonsPanel.IsVisible = false; + + // Set the options panel to be visible. + _optionsPanel.IsVisible = true; + + // Give the back button on the options panel focus. + _optionsBackButton.IsFocused = true; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handlesfxsliderchangecompleted.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handlesfxsliderchangecompleted.cs new file mode 100644 index 00000000..ae9bfcb5 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handlesfxsliderchangecompleted.cs @@ -0,0 +1,5 @@ +private void HandleSfxSliderChangeCompleted(object sender, EventArgs e) +{ + // Play the UI Sound effect so the player can hear the difference in audio. + Core.Audio.PlaySoundEffect(_uiSoundEffect); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handlesfxsliderchanged.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handlesfxsliderchanged.cs new file mode 100644 index 00000000..dc296455 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handlesfxsliderchanged.cs @@ -0,0 +1,12 @@ + private void HandleSfxSliderChanged(object sender, EventArgs args) + { + // Intentionally not playing the UI sound effect here so that it is not + // constantly triggered as the user adjusts the slider's thumb on the + // track. + + // Get a reference to the sender as a Slider. + var slider = (Slider)sender; + + // Set the global sound effect volume to the value of the slider.; + Core.Audio.SoundEffectVolume = (float)slider.Value; + } \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handlestartclicked.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handlestartclicked.cs new file mode 100644 index 00000000..d9b6feaa --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/handlestartclicked.cs @@ -0,0 +1,8 @@ +private void HandleStartClicked(object sender, EventArgs e) +{ + // A UI interaction occurred, play the sound effect + Core.Audio.PlaySoundEffect(_uiSoundEffect); + + // Change to the game scene to start the game. + Core.ChangeScene(new GameScene()); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/initialize.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/initialize.cs new file mode 100644 index 00000000..645e4ad3 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/initialize.cs @@ -0,0 +1,28 @@ +public override void Initialize() +{ + // LoadContent is called during base.Initialize(). + base.Initialize(); + + // While on the title screen, we can enable exit on escape so the player + // can close the game by pressing the escape key. + Core.ExitOnEscape = true; + + // Set the position and origin for the Dungeon text. + Vector2 size = _font5x.MeasureString(DUNGEON_TEXT); + _dungeonTextPos = new Vector2(640, 100); + _dungeonTextOrigin = size * 0.5f; + + // Set the position and origin for the Slime text. + size = _font5x.MeasureString(SLIME_TEXT); + _slimeTextPos = new Vector2(757, 207); + _slimeTextOrigin = size * 0.5f; + + // Initialize the offset of the background pattern at zero + _backgroundOffset = Vector2.Zero; + + // Set the background pattern destination rectangle to fill the entire + // screen background + _backgroundDestination = Core.GraphicsDevice.PresentationParameters.Bounds; + + InitializeUI(); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/initializeui.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/initializeui.cs new file mode 100644 index 00000000..eda5376a --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/initializeui.cs @@ -0,0 +1,9 @@ +private void InitializeUI() +{ + // Clear out any previous UI in case we came here from + // a different screen: + GumService.Default.Root.Children.Clear(); + + CreateTitlePanel(); + CreateOptionsPanel(); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/loadcontent.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/loadcontent.cs new file mode 100644 index 00000000..5e79274c --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/loadcontent.cs @@ -0,0 +1,14 @@ +public override void LoadContent() +{ + // Load the font for the standard text. + _font = Core.Content.Load("fonts/04B_30"); + + // Load the font for the title text + _font5x = Content.Load("fonts/04B_30_5x"); + + // Load the background pattern texture. + _backgroundPattern = Content.Load("images/background-pattern"); + + // Load the sound effect to play when ui actions occur. + _uiSoundEffect = Core.Content.Load("audio/ui"); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/update.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/update.cs new file mode 100644 index 00000000..d1f32cf4 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/update.cs @@ -0,0 +1,15 @@ +public override void Update(GameTime gameTime) +{ + // Update the offsets for the background pattern wrapping so that it + // scrolls down and to the right. + float offset = _scrollSpeed * (float)gameTime.ElapsedGameTime.TotalSeconds; + _backgroundOffset.X -= offset; + _backgroundOffset.Y -= offset; + + // Ensure that the offsets do not go beyond the texture bounds so it is + // a seamless wrap + _backgroundOffset.X %= _backgroundPattern.Width; + _backgroundOffset.Y %= _backgroundPattern.Height; + + GumService.Default.Update(gameTime); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/usings.cs b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/usings.cs new file mode 100644 index 00000000..228e8dc1 --- /dev/null +++ b/articles/tutorials/building_2d_games/20_implementing_ui_with_gum/snippets/titlescene/usings.cs @@ -0,0 +1,10 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameGum; +using MonoGameGum.Forms.Controls; +using MonoGameGum.GueDeriving; +using MonoGameLibrary; +using MonoGameLibrary.Scenes; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/files/04b_30.fnt b/articles/tutorials/building_2d_games/21_customizing_gum_ui/files/04b_30.fnt new file mode 100644 index 00000000..3c1a1c50 --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/files/04b_30.fnt @@ -0,0 +1,99 @@ +info face="04b30" size=35 bold=0 italic=0 charset="" unicode=1 stretchH=100 smooth=0 aa=1 padding=0,0,0,0 spacing=1,1 outline=0 +common lineHeight=35 base=31 scaleW=256 scaleH=512 pages=1 packed=0 alphaChnl=0 redChnl=4 greenChnl=4 blueChnl=4 +page id=0 file="../images/atlas.png" +chars count=95 +char id=32 x=30 y=152 width=3 height=1 xoffset=-1 yoffset=34 xadvance=29 page=0 chnl=15 +char id=33 x=240 y=30 width=14 height=29 xoffset=1 yoffset=2 xadvance=19 page=0 chnl=15 +char id=34 x=102 y=232 width=25 height=15 xoffset=1 yoffset=4 xadvance=29 page=0 chnl=15 +char id=35 x=184 y=0 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=36 x=250 y=150 width=3 height=1 xoffset=-1 yoffset=34 xadvance=29 page=0 chnl=15 +char id=37 x=0 y=34 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=38 x=30 y=150 width=3 height=1 xoffset=-1 yoffset=34 xadvance=29 page=0 chnl=15 +char id=39 x=245 y=202 width=10 height=15 xoffset=1 yoffset=4 xadvance=14 page=0 chnl=15 +char id=40 x=106 y=150 width=16 height=29 xoffset=1 yoffset=2 xadvance=21 page=0 chnl=15 +char id=41 x=123 y=150 width=16 height=29 xoffset=1 yoffset=2 xadvance=21 page=0 chnl=15 +char id=42 x=128 y=232 width=14 height=15 xoffset=1 yoffset=2 xadvance=19 page=0 chnl=15 +char id=43 x=94 y=0 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=44 x=143 y=232 width=10 height=14 xoffset=1 yoffset=19 xadvance=14 page=0 chnl=15 +char id=45 x=154 y=232 width=25 height=11 xoffset=1 yoffset=12 xadvance=29 page=0 chnl=15 +char id=46 x=231 y=228 width=10 height=10 xoffset=1 yoffset=19 xadvance=14 page=0 chnl=15 +char id=47 x=60 y=60 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=48 x=90 y=60 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=49 x=46 y=150 width=19 height=29 xoffset=1 yoffset=2 xadvance=23 page=0 chnl=15 +char id=50 x=150 y=60 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=51 x=180 y=60 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=52 x=210 y=60 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=53 x=0 y=94 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=54 x=180 y=30 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=55 x=60 y=90 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=56 x=90 y=90 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=57 x=120 y=90 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=58 x=234 y=202 width=10 height=25 xoffset=1 yoffset=4 xadvance=14 page=0 chnl=15 +char id=59 x=244 y=0 width=10 height=29 xoffset=1 yoffset=4 xadvance=14 page=0 chnl=15 +char id=60 x=86 y=150 width=19 height=29 xoffset=1 yoffset=2 xadvance=23 page=0 chnl=15 +char id=61 x=182 y=176 width=25 height=25 xoffset=1 yoffset=4 xadvance=29 page=0 chnl=15 +char id=62 x=237 y=120 width=18 height=29 xoffset=1 yoffset=2 xadvance=23 page=0 chnl=15 +char id=63 x=180 y=120 width=28 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=64 x=34 y=150 width=3 height=1 xoffset=-1 yoffset=34 xadvance=29 page=0 chnl=15 +char id=65 x=120 y=120 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=66 x=150 y=120 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=67 x=124 y=0 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=68 x=154 y=0 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=69 x=214 y=0 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=70 x=30 y=30 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=71 x=60 y=30 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=72 x=90 y=30 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=73 x=240 y=90 width=14 height=29 xoffset=1 yoffset=2 xadvance=19 page=0 chnl=15 +char id=74 x=120 y=30 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=75 x=150 y=30 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=76 x=209 y=120 width=27 height=29 xoffset=1 yoffset=2 xadvance=31 page=0 chnl=15 +char id=77 x=30 y=0 width=31 height=29 xoffset=1 yoffset=2 xadvance=35 page=0 chnl=15 +char id=78 x=210 y=30 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=79 x=0 y=64 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=80 x=30 y=60 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=81 x=0 y=0 width=29 height=33 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=82 x=120 y=60 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=83 x=30 y=90 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=84 x=150 y=90 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=85 x=180 y=90 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=86 x=210 y=90 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=87 x=62 y=0 width=31 height=29 xoffset=1 yoffset=2 xadvance=35 page=0 chnl=15 +char id=88 x=0 y=124 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=89 x=30 y=120 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=90 x=60 y=120 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=91 x=240 y=60 width=14 height=29 xoffset=1 yoffset=2 xadvance=19 page=0 chnl=15 +char id=92 x=90 y=120 width=29 height=29 xoffset=1 yoffset=2 xadvance=33 page=0 chnl=15 +char id=93 x=140 y=150 width=14 height=29 xoffset=1 yoffset=2 xadvance=19 page=0 chnl=15 +char id=94 x=180 y=232 width=16 height=11 xoffset=1 yoffset=4 xadvance=21 page=0 chnl=15 +char id=95 x=0 y=262 width=29 height=10 xoffset=1 yoffset=21 xadvance=33 page=0 chnl=15 +char id=96 x=197 y=228 width=16 height=11 xoffset=1 yoffset=4 xadvance=21 page=0 chnl=15 +char id=97 x=208 y=176 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=98 x=0 y=210 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=99 x=26 y=210 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=100 x=52 y=206 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=101 x=78 y=206 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=102 x=104 y=206 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=103 x=130 y=206 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=104 x=156 y=206 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=105 x=234 y=176 width=12 height=25 xoffset=1 yoffset=6 xadvance=16 page=0 chnl=15 +char id=106 x=182 y=202 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=107 x=208 y=202 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=108 x=78 y=232 width=23 height=25 xoffset=1 yoffset=6 xadvance=27 page=0 chnl=15 +char id=109 x=197 y=150 width=26 height=25 xoffset=1 yoffset=6 xadvance=31 page=0 chnl=15 +char id=110 x=0 y=236 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=111 x=26 y=236 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=112 x=78 y=180 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=113 x=0 y=154 width=25 height=29 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=114 x=52 y=232 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=115 x=224 y=150 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=116 x=0 y=184 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=117 x=26 y=184 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=118 x=52 y=180 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=119 x=170 y=150 width=26 height=25 xoffset=1 yoffset=6 xadvance=31 page=0 chnl=15 +char id=120 x=104 y=180 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=121 x=130 y=180 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=122 x=156 y=180 width=25 height=25 xoffset=1 yoffset=6 xadvance=29 page=0 chnl=15 +char id=123 x=26 y=154 width=19 height=29 xoffset=1 yoffset=2 xadvance=23 page=0 chnl=15 +char id=124 x=155 y=150 width=14 height=29 xoffset=1 yoffset=2 xadvance=19 page=0 chnl=15 +char id=125 x=66 y=150 width=19 height=29 xoffset=1 yoffset=2 xadvance=23 page=0 chnl=15 +char id=126 x=214 y=228 width=16 height=11 xoffset=1 yoffset=4 xadvance=21 page=0 chnl=15 diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/images/atlas.png b/articles/tutorials/building_2d_games/21_customizing_gum_ui/images/atlas.png new file mode 100644 index 00000000..f7def20f Binary files /dev/null and b/articles/tutorials/building_2d_games/21_customizing_gum_ui/images/atlas.png differ diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/images/mgcb-editor.png b/articles/tutorials/building_2d_games/21_customizing_gum_ui/images/mgcb-editor.png new file mode 100644 index 00000000..0fcd0479 Binary files /dev/null and b/articles/tutorials/building_2d_games/21_customizing_gum_ui/images/mgcb-editor.png differ diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/index.md b/articles/tutorials/building_2d_games/21_customizing_gum_ui/index.md new file mode 100644 index 00000000..346405f6 --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/index.md @@ -0,0 +1,436 @@ +--- +title: "Chapter 21: Customizing Gum UI" +description: "Learn how to create custom UI components with animations and visual styling in Gum." +--- + +In the [previous chapter](../20_implementing_ui_with_gum/index.md), we implemented a functional UI system for our game using the Gum framework. While the UI is now fully operational, it uses Gum's default styling. This default styling is good for quickly iterating when building the UI, but it does not match the game's visuals. A well designed UI should not only be functional but also complement the game's overall visual style to create a cohesive experience. + +In this chapter you will: + +- Learn about Gum's visual customization system and component hierarchy. +- Understand how animation chains and visual states work in Gum. +- Create custom styled button and slider components. +- Update the game's texture atlas to include UI graphics. +- Implement responsive visual feedback for player interactions. +- Apply your custom components to the game's UI screens. + +## Understanding Gum's Customization System + +Gum provides a powerful customization system that separates a UI element's functionality from its appearance. This allows you to maintain the built-in behavior of standard controls while completely changing their visual representation. + +### Container Hierarchy + +Every customized UI component in Gum starts with a top-level container that holds all other visual elements. This container is typically of type `ContainerRuntime`, which is similar to the `Panel` type we used earlier, but specifically designed for building custom visuals. + +The container hierarchy follows a parent-child relationship: + +- The top-level container manages the overall size and positioning of the component. +- Visual elements like backgrounds, text, and icons are added as children. +- Child elements can be positioned relative to their parent container. +- Child elements can also be nested within other children, creating deeper hierarchies. + +This hierarchical structure allows you to build complex UI components from simpler parts, with each part playing a specific role in the overall design. + +### Size Relationships with Width and WidthUnits + +One powerful feature of Gum is how it handles size relationships between parent and child elements. By using different `WidthUnits` values, you can create dependencies that flow in different directions: + +- **RelativeToChildren**: A parent container can size itself based on its children. +- **PercentageOfParent**: A child element can size itself as a percentage of its parent. +- **Absolute**: An element can have a fixed pixel size. +- **RelativeToParent**: An element can size itself relative to a specific container. + +For example: + +- A button might use a text element with `WidthUnits` set to `RelativeToChildren`, which means the text will be exactly the size needed to display its content. +- The button's container might use `RelativeToChildren` with some additional padding, allowing the button to automatically resize based on its text content. + +Although we have not explicitly assigned WidthUnits and HeightUnits in our code, we have indirectly set these values by calling the Visual's `Dock` method. Specifically, by passing `Dock.Fill` as the parameter, `WidthUnits` and `HeightUnits` are both set to `RelativeToParent`. + +> [!NOTE] +> These size relationships can create circular dependencies when a child depends on its parent and the parent depends on the child. In such cases, Gum resolves the conflict by making the child depend on the parent, and the parent ignores that particular child when calculating its size. + +### Visual Elements + +Gum provides several visual element types that we can use to build our custom components: + +- **ContainerRuntime**: An invisible container for organizing other elements. +- **NineSliceRuntime**: A special graphic that can stretch while preserving its corners and edges. +- **TextRuntime**: An element for displaying text with custom fonts. +- **ColoredRectangleRuntime**: A simple colored rectangle for backgrounds or fills. + +The `NineSliceRuntime` is particularly useful for UI elements that need to resize dynamically. It divides a graphic into nine sections (four corners, four edges, and a center), allowing the element to stretch without distorting its borders. + +> [!NOTE] +> A MonoGame and Gum community member Kaltinril also has a video series discussing Gum. With permission, the following video segment is included to demonstrate the advantages of using a *Nineslice* when creating UI elements. +>
+> +>
+ +#### Animation Chains + +An `AnimationChain` is a sequence of animation frames that play in order, typically looping after the last frame. Each frame in the chain defines: + +- Which part of a texture to display (using texture coordinates). +- How long to display that frame (using a frame length value). +- Which texture to use for the frame. + +Texture coordinates in Gum use normalized values (0.0 to 1.0) rather than pixel coordinates, where: + +- 0.0 represents the left or top edge of the texture. +- 1.0 represents the right or bottom edge of the texture. + +To convert from pixel coordinates to normalized values, you divide the pixel position by the texture's width or height. + +#### Visual States + +Rather than directly modifying properties when UI elements change state (like when a button is focused), Gum uses a state-based system. Each control type has a specific category name that identifies its collection of states: + +- Buttons use `Button.ButtonCategoryName`. +- Sliders use `Slider.SliderCategoryName`. +- Other control types have their own category names. + +Within each category, you define named states that correspond to the control's possible conditions: + +- "Enabled" (the normal, unfocused state). +- "Focused" (when the control has focus). +- "Highlighted" (when the mouse hovers over the control). +- "Disabled" (when the control cannot be interacted with). + +Each state contains an `Apply` action that defines what visual changes occur when that state becomes active. For example, when a button becomes focused, its state might change the background color or switch to an animated version. + +### Input and Focus Handling + +Custom UI components can enhance their interactivity by handling specific input events: + +- The `KeyDown` event can be used to add custom keyboard navigation. +- The `RollOn` event can detect when the mouse moves over the component. +- The `Click` event can respond to mouse clicks or gamepad button presses. + +Gum distinguishes between highlighting (visual response to mouse hover) and focus (ability to receive keyboard/gamepad input). For a seamless experience across input devices, a common pattern is to automatically focus elements when the mouse hovers over them, ensuring that visual highlighting and input focus remain synchronized. + +Now that we understand the key concepts behind Gum's customization system, we can apply them to create custom UI components for our game. + +## Updating the Game Resources + +Before we create our custom components, we need to update the game's resources to include UI graphics and fonts. + +### Update the Texture Atlas + +First need to update the *atlas.png* texture atlas file for the game. This new version of the texture atlas includes: + +- The characters for the font, generated using Bitmap Font Generator (BMFont) +- The sprites for the UI components we will create + +Download the new texture atlas below by right-clicking the following image and saving it as *atlas.png* in the *Content/images* folder of the game project, overwriting the existing one. + +| ![Figure 21-1: The texture atlas for the game updated to include the UI sprites](./images/atlas.png) | +| :--------------------------------------------------------------------------------------------------: | +| **Figure 21-1: The texture atlas for the game updated to include the UI sprites** | + +The slime and bat sprites are no longer in the same position, and we have some new regions to define for our UI sprites. This means we need to update the texture atlas XML configuration file as well. Open the *atlas-definition.xml* configuration file and update it to the following: + +[!code-csharp[](./snippets/atlas-definition.xml?highlight=5-16,29-32)] + +The same is now true for the tiles in the texture atlas. Since they have been repositioned in the new texture atlas, we need to update the `region` attribute for the tilemap XML configuration file. Open the `tilemap-definition.xml` configuration file and update it to the following: + +[!code-csharp[](./snippets/tilemap-definition.xml?highlight=3)] + +### Adding Bitmap Fonts + +While MonoGame natively uses [**SpriteFont**](xref:Microsoft.Xna.Framework.Graphics.SpriteFont) to draw text, Gum uses the [AngelCode Bitmap Font (.fnt)](https://www.angelcode.com/products/bmfont/) font file format. This means we will need to supply Gum with the *.fnt* file that defines our font. + +> [!NOTE] +> For this tutorial, a pregenerated *.fnt* file is supplied below. For more information on creating *.fnt* files for Gum, see the [Create Fonts with BitmapFontGenerator](https://docs.flatredball.com/gum/gum-tool/gum-elements/text/use-custom-font#creating-fonts-with-bitmapfontgenerator) section of the Gum documentation. + +Download the *.fnt* file below by right-clicking the following link and saving it as *04b_30.fnt* in the game project's *Content/fonts* folder: + +- [04b_30.fnt](./files/04b_30.fnt){download} + +Next, add this font file to your content project using the MGCB Editor: + +1. Open the `Content.mgcb` content project file in the MGCB Editor. +2. Right-click the `fonts` folder and choose `Add > Existing Item...`. +3. Navigate to and select the `04b_30.fnt` file you just downloaded. +4. In the Properties panel, change the `Build Action` to `Copy`. The MonoGame Content Pipeline cannot process *.fnt* files; we just need it to copy it so we can give it to Gum. +5. **Save the changes and close the MGCB Editor.** + +| ![Figure 21-2: The MGCB Editor with the 04b_30.fnt added to the fonts folder and the Build property set to Copy](./images/mgcb-editor.png) | +| :----------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 21-2: The MGCB Editor with the 04b_30.fnt added to the fonts folder and the Build property set to Copy** | + +> [!NOTE] +> When the *.fnt* font file was generated using the [AngelCode Bitmap Font Generator](https://www.angelcode.com/products/bmfont/), the graphics from the *.png* file that it produces was copied over into our existing texture atlas. By doing this, it allows Gum to render the visuals for elements and the text from the same atlas, reducing texture swapping. +> +> The font file references our existing texture atlas using a relative path that points to the atlas image. +> +> The best practice when using this method is to ensure that when you copy the graphics from the generated *.png* file to your texture atlas, you place the generated character glyph graphics in the top-left of your texture atlas. This means all of the coordinates in the *.fnt* file will correctly reference the character glyphs without additional manual changes. + +## Updating the TextureRegion Class + +In [Chapter 18](../18_texture_sampling/index.md#texture-coordinates) we discussed texture coordinates and that graphic devices use a normalized coordinate system between 0.0 and 1.0. + +Since [Gum also uses this coordinate system](#animation-chains), we will update the `TextureRegion` class to easily provide these values for any given region. + +Open the `TextureRegion.cs` file in the *MonoGameLibrary* project and add the following properties to the `TextureRegion` class: + +[!code-csharp[](./snippets/textureregion/properties.cs)] + +## Creating Custom UI Components + +Now that we have all our resources prepared, we can create custom versions of the UI controls we are using in our game. We will start with an animated button that uses our game's visual style, then move on to creating a custom slider. + +### The AnimatedButton Class + +Our first custom component will be an `AnimatedButton` that inherits from Gum's base `Button` class. This button will use the game's existing texture atlas for its visual appearance and provide animation when focused. + +First, in the *DungeonSlime* project (your main game project), create a new folder named `UI` to store our custom UI components. Next, in that `UI` folder, create a new file called `AnimatedButton.cs` and add the following code to it: + +[!code-csharp[](./snippets/animatedbutton.cs)] + +Next, we will examine the key aspects of this new `AnimatedButton` implementation: + +#### Top-level Container + +Every customized control needs a top-level container to hold all visual elements. For our button, we create a `ContainerRuntime` that manages the button's size and contains all other visual elements: + +[!code-csharp[](./snippets/animatedbutton.cs?start=26&end=32)] + +The `WidthUnits` property set to `RelativeToChildren` means the container will automatically size itself based on its child elements, with 21 pixels of additional space. This allows the button to adapt its size depending on the text content. + +#### Nine-slice Background + +We use a `NineSliceRuntime` for the button's background. A nine-slice is a special graphic that can be stretch while preserving its corners and edges: + +[!code-csharp[](./snippets/animatedbutton.cs?start=34&end=41)] + +The `TextureAddress` property is set to `Custom` so we can specify exactly which portion of the atlas texture to use, while `Dock(Dock.Fill)` ensure the background fills the entire button area. + +#### Animated Chains + +The most distinctive feature of our animated button is its ability to change appearance when focused. We achieve this by creating two animation chains: + +1. An "unfocused" animation with a single static frame. +2. A "focused" animation with two alternating frames that create a visual effect. + +Each animation frame specifies the coordinates within our texture atlas to display: + +[!code-csharp[](./snippets/animatedbutton.cs?start=62&end=102)] + +#### States and Categories + +In Gum, each control type has a specific category name that identifies its state collection. For buttons we use `Button.ButtonCategoryName`: + +[!code-csharp[](./snippets/animatedbutton.cs?start=104&end=107)] + +Within this category, we define how the button appears in different states by creating `StateSave` objects with specific state names: + +[!code-csharp[](./snippets/animatedbutton.cs?start=109&end=140)] + +Each state's `Apply` action defines what visual changes occur when the state becomes active. In our case, we switch between animation chains to create the desired visual effect. + +#### Custom Input Handling + +We add custom keyboard navigation to our button by handling the `KeyDown` event: + +[!code-csharp[](./snippets/animatedbutton.cs?start=142&end=143)] + +[!code-csharp[](./snippets/animatedbutton.cs?start=152&end=167)] + +This allows players to navigate between buttons using the left and right arrow keys, providing additional control options beyond the default tab navigation. + +#### Focus Management + +We also add a `RollOn` event handler to ensure the button gets focus when the mouse hovers over it: + +[!code-csharp[](./snippets/animatedbutton.cs?start=145&end=146)] + +[!code-csharp[](./snippets/animatedbutton.cs?start=169&end=175)] + +This creates a more responsive interface by immediately focusing elements that the player interacts with using the mouse. + +### The OptionsSlider Class + +Now we will create a custom `OptionsSlider` class to style the volume sliders. This class inherits from Gum's base `Slider` class and provides a styled appearance consistent with the game's visual theme. + +In the `UI` folder of the *DungeonSlime* project (your main game project), create a new file called `OptionsSlider.cs` and add the following code to it: + +[!code-csharp[](./snippets/optionsslider.cs)] + +The `OptionsSlider` is more complex than then [`AnimatedButton`](#the-animatedbutton-class) because it contains more visual elements. Below are the key aspects of this implementation: + +#### Slider Components + +Walking through the `OptionsSlider` implementation, it consists of several components + +1. A background container with a label for the slider. +2. An inner container that holds the slider track. +3. "OFF" and "MAX" section at each end of the slider. +4. A track where the thumb moves. +5. A fill rectangle that shows the current value visually. + +Each of these elements is styled to match the game's visual theme using sprites from our atlas. + +#### Custom Text Property + +We add a custom `Text` property to set the slider's label: + +[!code-csharp[](./snippets/optionsslider.cs?start=24&end=31)] + +This allows us to easily customize the label for each slider instance we create. + +#### Visual Feedback + +The slider uses color changes to provide visual feedback: + +[!code-csharp[](./snippets/optionsslider.cs?start=173&end=186)] + +[!code-csharp[](./snippets/optionsslider.cs?start=188&end=201)] + +When the slider is focused, all its elements change from gray to white, making it clear to the player which UI element currently has focus. + +#### Fill Visualization + +One of the most important aspects of a slider is the visual representation of its value. We achieve this by updating the width of the `_fillRectangle` element: + +[!code-csharp[](./snippets/optionsslider.cs?start=242&end=253)] + +This method converts the slider's current value to a percentage and applies it to the fill rectangle's width, creating a visual indicator of the current setting. + +## Updating the Scenes to Use Custom Controls + +Now that we have created our custom controls, we need to update our game scenes to use them instead of the default Gum controls. + +### Updating the TitleScene + +First, open the `TitleScene.cs` file in the game project and add the following using declaration to the top of the `TitleScene` class: + +[!code-csharp[](./snippets/titlescene/usings.cs?highlight=2,11)] + +Next, update both the `_optionsButton` and the `_optionsBackButton` fields to be of our new [`AnimatedButton`](#the-animatedbutton-class) type, and add a new field to store a reference to the texture atlas in. + +[!code-csharp[](./snippets/titlescene/fields.cs?highlight=3-11)] + +Next, in the `LoadContent` method, we need to update it so that it loads the texture atlas from the XML configuration file and stores it in the new `_atlas` field: + +[!code-csharp[](./snippets/titlescene/loadcontent.cs?highlight=15-16)] + +Next, update the `CreateTitlePanel` method so that instead of using the default Gum `Button` Forms controls it now uses our custom [`AnimatedButton`](#the-animatedbutton-class) control and remove the explicit setting of the `Visual.Width` property since this is managed by the [`AnimatedButton`](#the-animatedbutton-class) now: + +[!code-csharp[](./snippets/titlescene/createtitlepanel.cs?highlight=8,16)] + +Finally, update the `CreateOptionsPanel` method so that: + +- It uses a `TextRuntime` to display the text "OPTIONS" using the bitmap font +- Instead of using the default Gum `Button` and `Slider` Forms controls, it now uses our custom [`AnimatedButton`](#the-animatedbutton-class) and [`OptionsSlider`](#the-optionsslider-class) controls. +- Both the `musicSlider` and `sfxSlider` have been given `Name` and `Text` properties. + +[!code-csharp[](./snippets/titlescene/createoptionspanel.cs?highlight=8-15,17-19,31-33,45)] + +### Updating the GameScene + +Next, open the `GameScene.cs` file in the game project and add the following using declaration to the top of the `GameScene` class: + +[!code-csharp[](./snippets/gamescene/usings.cs?highlight=2,4)] + +Next, update the `_resumeButton` field to be of our new [`AnimatedButton`](#the-animatedbutton-class) type and add a field to store a reference to the texture atlas in. + +[!code-csharp[](./snippets/gamescene/fields.cs?highlight=5,10-12)] + +Next, in the `LoadContent` method, we need to update it so that it stores the texture atlas once loaded in the new `_atlas` field. + +[!code-csharp[](./snippets/gamescene/loadcontent.cs?highlight=4,7,11)] + +Finally, update the `CreatePausePanel` method so that + +1. Instead of using a [`ColoredRectangleRuntime`](#visual-elements) for the background of the pause panel, it now uses a [`NineSliceRuntime`](#visual-elements) that uses the sprite from the texture atlas. +2. The `textInstance` is updated so that it uses the custom bitmap font file. +3. The `_resumeButton` and `quiteButton` are updated to use our custom [`AnimatedButton`](#the-animatedbutton-class) control instead of the default Gum `Button` Forms control. + +[!code-csharp[](./snippets/gamescene/createpausepanel.cs?highlight=12-22,26-28,33,41)] + +## Testing the Styled UI + +When you run the game now, you will see a dramatic improvement in the visual appearance of the UI: + +1. The buttons now use our custom animated background that pulses when focused. +2. The sliders have a cleaner, mores stylized appearance with the OFF and MAX labels. +3. All text uses our custom bitmap font. +4. Visual feedback clearly indicates which element has focus. + +| ![Figure 21-3: The game using Gum now with custom styled UI components](./videos/gameplay.webm) | +| :---------------------------------------------------------------------------------------------: | +| **Figure 21-3: The game using Gum now with custom styled UI components** | + +The entire UI now has a cohesive style that matches the rest of the game. + +## Conclusion + +In this chapter, you learned how to transform basic UI components into custom, styled elements that match the game's visual theme. You explored several key aspects of UI customization: + +- How container hierarchies and size relationships work in Gum. +- Creating animation chains for visual feedback. +- Using the state system to respond to user interactions. +- Building complex custom controls by extending base classes. +- Integrating custom fonts and graphics from a texture atlas. + +By creating reusable custom controls, you have not only improved the look of your game, but you have also developed components that can be used in future projects. This approach of separating functionality from appearance allows you to maintain consistent behavior while completely changing the visual style to match different games. + +The principles you have learned in this chapter extend beyond the specific components we created. You can apply the same techniques to create other custom UI elements like checkboxes, radio buttons, scroll panels, and more. By understanding how to build on Gum's foundation, you have the tools to create any UI component your game might need. + +## Test Your Knowledge + +1. What are the two main approaches to customizing visuals in Gum, and when would you use each one? + + :::question-answer + The two main approaches are: + + - **Direct property assignment**: Setting properties directly in code (like `button.Visual.Width = 100`). This approach is best for initial setup of UI elements and static properties that do not change during gameplay. + - **States (StateSave objects)**: Defining different visual states that are applied automatically in response to interactions. This approach is best for dynamic changes that happen during gameplay, like highlighting a button when it is focused or changing colors when a slider is adjusted. + + ::: + +2. What is the purpose of using a top-level container in a custom Gum control? + + :::question-answer + A top-level container in a custom Gum control serves several purposes: + + - It provides a single parent element that holds all visual components of the control. + - It establishes the coordinate system for positioning child elements. + - It can manage the overall size of the control (often using `RelativeToChildren` sizing). + - It serves as the attachment point for states and categories. + - It creates a clear separation between the control's visuals and its functionality. + + ::: + +3. How do animation chains work in Gum, and what are the key components needed to create one? + + :::question-answer + Animation chains in Gum work by displaying a sequence of frames in order, typically looping after the last frame. The key components needed to create an animation chain are: + 1. An `AnimationChain` object to hold the sequence of frames + 2. Multiple `AnimationFrame` objects, each with: + - Texture coordinates (left, right, top, bottom) defining which part of the texture to display + - A frame length value determining how long to display the frame + - A reference to the texture where the frame appears + 3. A method to add the animation to a visual element (like assigning to a NineSliceRuntime's CurrentChainName) + + The animation system uses normalized texture coordinates (0.0 to 1.0) rather than pixel coordinates. + ::: + +4. What is the relationship between Gum's state system and Forms controls, and why is it important? + + :::question-answer + Gum's state system links with Forms controls through specifically named categories and states: + + - Each Forms control type has a reserved category name (e.g., Button.ButtonCategoryName) + - Within that category, the control looks for states with specific names (Enabled, Focused, Highlighted, etc.) + - When the control's state changes (like gaining focus), it automatically applies the corresponding visual state + + This relationship is important because it: + + - Separates the control's functionality from its appearance + - Enables consistent behavior while allowing complete visual customization + - Provides automatic visual feedback in response to user interactions without requiring manual state management + - Makes it easier to create controls that work with mouse, keyboard, and gamepad input. + + ::: diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/animatedbutton.cs b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/animatedbutton.cs new file mode 100644 index 00000000..0e579ab4 --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/animatedbutton.cs @@ -0,0 +1,175 @@ +using System; +using Gum.DataTypes; +using Gum.DataTypes.Variables; +using Gum.Graphics.Animation; +using Gum.Managers; +using Microsoft.Xna.Framework.Input; +using MonoGameGum.Forms.Controls; +using MonoGameGum.GueDeriving; +using MonoGameLibrary.Graphics; + +namespace DungeonSlime.UI; + +/// +/// A custom button implementation that inherits from Gum's Button class to provide +/// animated visual feedback when focused. +/// +internal class AnimatedButton : Button +{ + /// + /// Creates a new AnimatedButton instance using graphics from the specified texture atlas. + /// + /// The texture atlas containing button graphics and animations + public AnimatedButton(TextureAtlas atlas) + { + // Create the top-level container that will hold all visual elements + // Width is relative to children with extra padding, height is fixed + ContainerRuntime topLevelContainer = new ContainerRuntime(); + topLevelContainer.Height = 14f; + topLevelContainer.HeightUnits = DimensionUnitType.Absolute; + topLevelContainer.Width = 21f; + topLevelContainer.WidthUnits = DimensionUnitType.RelativeToChildren; + + // Create the nine-slice background that will display the button graphics + // A nine-slice allows the button to stretch while preserving corner appearance + NineSliceRuntime nineSliceInstance = new NineSliceRuntime(); + nineSliceInstance.Height = 0f; + nineSliceInstance.Texture = atlas.Texture; + nineSliceInstance.TextureAddress = TextureAddress.Custom; + nineSliceInstance.Dock(Gum.Wireframe.Dock.Fill); + topLevelContainer.Children.Add(nineSliceInstance); + + // Create the text element that will display the button's label + TextRuntime textInstance = new TextRuntime(); + // Name is required so it hooks in to the base Button.Text property + textInstance.Name = "TextInstance"; + textInstance.Text = "START"; + textInstance.Blue = 130; + textInstance.Green = 86; + textInstance.Red = 70; + textInstance.UseCustomFont = true; + textInstance.CustomFontFile = "fonts/04b_30.fnt"; + textInstance.FontScale = 0.25f; + textInstance.Anchor(Gum.Wireframe.Anchor.Center); + textInstance.Width = 0; + textInstance.WidthUnits = DimensionUnitType.RelativeToChildren; + topLevelContainer.Children.Add(textInstance); + + // Get the texture region for the unfocused button state from the atlas + TextureRegion unfocusedTextureRegion = atlas.GetRegion("unfocused-button"); + + // Create an animation chain for the unfocused state with a single frame + AnimationChain unfocusedAnimation = new AnimationChain(); + unfocusedAnimation.Name = nameof(unfocusedAnimation); + AnimationFrame unfocusedFrame = new AnimationFrame + { + TopCoordinate = unfocusedTextureRegion.TopTextureCoordinate, + BottomCoordinate = unfocusedTextureRegion.BottomTextureCoordinate, + LeftCoordinate = unfocusedTextureRegion.LeftTextureCoordinate, + RightCoordinate = unfocusedTextureRegion.RightTextureCoordinate, + FrameLength = 0.3f, + Texture = unfocusedTextureRegion.Texture + }; + unfocusedAnimation.Add(unfocusedFrame); + + // Get the multi-frame animation for the focused button state from the atlas + Animation focusedAtlasAnimation = atlas.GetAnimation("focused-button-animation"); + + // Create an animation chain for the focused state using all frames from the atlas animation + AnimationChain focusedAnimation = new AnimationChain(); + focusedAnimation.Name = nameof(focusedAnimation); + foreach (TextureRegion region in focusedAtlasAnimation.Frames) + { + AnimationFrame frame = new AnimationFrame + { + TopCoordinate = region.TopTextureCoordinate, + BottomCoordinate = region.BottomTextureCoordinate, + LeftCoordinate = region.LeftTextureCoordinate, + RightCoordinate = region.RightTextureCoordinate, + FrameLength = (float)focusedAtlasAnimation.Delay.TotalSeconds, + Texture = region.Texture + }; + + focusedAnimation.Add(frame); + } + + // Assign both animation chains to the nine-slice background + nineSliceInstance.AnimationChains = new AnimationChainList + { + unfocusedAnimation, + focusedAnimation + }; + + // Create a state category for button states + StateSaveCategory category = new StateSaveCategory(); + category.Name = Button.ButtonCategoryName; + topLevelContainer.AddCategory(category); + + // Create the enabled (default/unfocused) state + StateSave enabledState = new StateSave(); + enabledState.Name = FrameworkElement.EnabledStateName; + enabledState.Apply = () => + { + // When enabled but not focused, use the unfocused animation + nineSliceInstance.CurrentChainName = unfocusedAnimation.Name; + }; + category.States.Add(enabledState); + + // Create the focused state + StateSave focusedState = new StateSave(); + focusedState.Name = FrameworkElement.FocusedStateName; + focusedState.Apply = () => + { + // When focused, use the focused animation and enable animation playback + nineSliceInstance.CurrentChainName = focusedAnimation.Name; + nineSliceInstance.Animate = true; + }; + category.States.Add(focusedState); + + // Create the highlighted+focused state (for mouse hover while focused) + // by cloning the focused state since they appear the same + StateSave highlightedFocused = focusedState.Clone(); + highlightedFocused.Name = FrameworkElement.HighlightedFocusedStateName; + category.States.Add(highlightedFocused); + + // Create the highlighted state (for mouse hover) + // by cloning the enabled state since they appear the same + StateSave highlighted = enabledState.Clone(); + highlighted.Name = FrameworkElement.HighlightedStateName; + category.States.Add(highlighted); + + // Add event handlers for keyboard input. + KeyDown += HandleKeyDown; + + // Add event handler for mouse hover focus. + topLevelContainer.RollOn += HandleRollOn; + + // Assign the configured container as this button's visual + Visual = topLevelContainer; + } + + /// + /// Handles keyboard input for navigation between buttons using left/right keys. + /// + private void HandleKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Keys.Left) + { + // Left arrow navigates to previous control + HandleTab(TabDirection.Up, loop: true); + } + if (e.Key == Keys.Right) + { + // Right arrow navigates to next control + HandleTab(TabDirection.Down, loop: true); + } + } + + /// + /// Automatically focuses the button when the mouse hovers over it. + /// + private void HandleRollOn(object sender, EventArgs e) + { + IsFocused = true; + } +} diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/atlas-definition.xml b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/atlas-definition.xml new file mode 100644 index 00000000..fae2327d --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/atlas-definition.xml @@ -0,0 +1,34 @@ + + + images/atlas + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/gamescene/createpausepanel.cs b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/gamescene/createpausepanel.cs new file mode 100644 index 00000000..16e7941f --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/gamescene/createpausepanel.cs @@ -0,0 +1,49 @@ +private void CreatePausePanel() +{ + _pausePanel = new Panel(); + _pausePanel.Anchor(Anchor.Center); + _pausePanel.Visual.WidthUnits = DimensionUnitType.Absolute; + _pausePanel.Visual.HeightUnits = DimensionUnitType.Absolute; + _pausePanel.Visual.Height = 70; + _pausePanel.Visual.Width = 264; + _pausePanel.IsVisible = false; + _pausePanel.AddToRoot(); + + TextureRegion backgroundRegion = _atlas.GetRegion("panel-background"); + + NineSliceRuntime background = new NineSliceRuntime(); + background.Dock(Dock.Fill); + background.Texture = backgroundRegion.Texture; + background.TextureAddress = TextureAddress.Custom; + background.TextureHeight = backgroundRegion.Height; + background.TextureLeft = backgroundRegion.SourceRectangle.Left; + background.TextureTop = backgroundRegion.SourceRectangle.Top; + background.TextureWidth = backgroundRegion.Width; + _pausePanel.AddChild(background); + + TextRuntime textInstance = new TextRuntime(); + textInstance.Text = "PAUSED"; + textInstance.CustomFontFile = @"fonts/04b_30.fnt"; + textInstance.UseCustomFont = true; + textInstance.FontScale = 0.5f; + textInstance.X = 10f; + textInstance.Y = 10f; + _pausePanel.AddChild(textInstance); + + _resumeButton = new AnimatedButton(_atlas); + _resumeButton.Text = "RESUME"; + _resumeButton.Anchor(Anchor.BottomLeft); + _resumeButton.Visual.X = 9f; + _resumeButton.Visual.Y = -9f; + _resumeButton.Click += HandleResumeButtonClicked; + _pausePanel.AddChild(_resumeButton); + + AnimatedButton quitButton = new AnimatedButton(_atlas); + quitButton.Text = "QUIT"; + quitButton.Anchor(Anchor.BottomRight); + quitButton.Visual.X = -9f; + quitButton.Visual.Y = -9f; + quitButton.Click += HandleQuitButtonClicked; + + _pausePanel.AddChild(quitButton); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/gamescene/fields.cs b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/gamescene/fields.cs new file mode 100644 index 00000000..e9c1f545 --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/gamescene/fields.cs @@ -0,0 +1,12 @@ +// Existing fields... + +// A reference to the resume button UI element so we can focus it +// when the game is paused. +private AnimatedButton _resumeButton; + +// The UI sound effect to play when a UI event is triggered. +private SoundEffect _uiSoundEffect; + +// Reference to the texture atlas that we can pass to UI elements when they +// are created. +private TextureAtlas _atlas; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/gamescene/loadcontent.cs b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/gamescene/loadcontent.cs new file mode 100644 index 00000000..34b93e2c --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/gamescene/loadcontent.cs @@ -0,0 +1,29 @@ +public override void LoadContent() +{ + // Create the texture atlas from the XML configuration file + _atlas = TextureAtlas.FromFile(Core.Content, "images/atlas-definition.xml"); + + // Create the slime animated sprite from the atlas. + _slime = _atlas.CreateAnimatedSprite("slime-animation"); + _slime.Scale = new Vector2(4.0f, 4.0f); + + // Create the bat animated sprite from the atlas. + _bat = _atlas.CreateAnimatedSprite("bat-animation"); + _bat.Scale = new Vector2(4.0f, 4.0f); + + // Create the tilemap from the XML configuration file. + _tilemap = Tilemap.FromFile(Content, "images/tilemap-definition.xml"); + _tilemap.Scale = new Vector2(4.0f, 4.0f); + + // Load the bounce sound effect + _bounceSoundEffect = Content.Load("audio/bounce"); + + // Load the collect sound effect + _collectSoundEffect = Content.Load("audio/collect"); + + // Load the font + _font = Core.Content.Load("fonts/04B_30"); + + // Load the sound effect to play when ui actions occur. + _uiSoundEffect = Core.Content.Load("audio/ui"); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/gamescene/usings.cs b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/gamescene/usings.cs new file mode 100644 index 00000000..0f66d95e --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/gamescene/usings.cs @@ -0,0 +1,16 @@ +using System; +using DungeonSlime.UI; +using Gum.DataTypes; +using Gum.Managers; +using Gum.Wireframe; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameGum; +using MonoGameGum.Forms.Controls; +using MonoGameGum.GueDeriving; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; +using MonoGameLibrary.Input; +using MonoGameLibrary.Scenes; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/optionsslider.cs b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/optionsslider.cs new file mode 100644 index 00000000..63f4fbb0 --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/optionsslider.cs @@ -0,0 +1,253 @@ +using System; +using Gum.DataTypes; +using Gum.DataTypes.Variables; +using Gum.Managers; +using Microsoft.Xna.Framework; +using MonoGameGum.Forms.Controls; +using MonoGameGum.GueDeriving; +using MonoGameLibrary.Graphics; + +namespace DungeonSlime.UI; + +/// +/// A custom slider control that inherits from Gum's Slider class. +/// +public class OptionsSlider : Slider +{ + // Reference to the text label that displays the slider's title + private TextRuntime _textInstance; + + // Reference to the rectangle that visually represents the current value + private ColoredRectangleRuntime _fillRectangle; + + /// + /// Gets or sets the text label for this slider. + /// + public string Text + { + get => _textInstance.Text; + set => _textInstance.Text = value; + } + + /// + /// Creates a new OptionsSlider instance using graphics from the specified texture atlas. + /// + /// The texture atlas containing slider graphics. + public OptionsSlider(TextureAtlas atlas) + { + // Create the top-level container for all visual elements + ContainerRuntime topLevelContainer = new ContainerRuntime(); + topLevelContainer.Height = 55f; + topLevelContainer.Width = 264f; + + TextureRegion backgroundRegion = atlas.GetRegion("panel-background"); + + // Create the background panel that contains everything + NineSliceRuntime background = new NineSliceRuntime(); + background.Texture = atlas.Texture; + background.TextureAddress = TextureAddress.Custom; + background.TextureHeight = backgroundRegion.Height; + background.TextureLeft = backgroundRegion.SourceRectangle.Left; + background.TextureTop = backgroundRegion.SourceRectangle.Top; + background.TextureWidth = backgroundRegion.Width; + background.Dock(Gum.Wireframe.Dock.Fill); + topLevelContainer.AddChild(background); + + // Create the title text element + _textInstance = new TextRuntime(); + _textInstance.CustomFontFile = @"fonts/04b_30.fnt"; + _textInstance.UseCustomFont = true; + _textInstance.FontScale = 0.5f; + _textInstance.Text = "Replace Me"; + _textInstance.X = 10f; + _textInstance.Y = 10f; + _textInstance.WidthUnits = DimensionUnitType.RelativeToChildren; + topLevelContainer.AddChild(_textInstance); + + // Create the container for the slider track and decorative elements + ContainerRuntime innerContainer = new ContainerRuntime(); + innerContainer.Height = 13f; + innerContainer.Width = 241f; + innerContainer.X = 10f; + innerContainer.Y = 33f; + topLevelContainer.AddChild(innerContainer); + + TextureRegion offBackgroundRegion = atlas.GetRegion("slider-off-background"); + + // Create the "OFF" side of the slider (left end) + NineSliceRuntime offBackground = new NineSliceRuntime(); + offBackground.Dock(Gum.Wireframe.Dock.Left); + offBackground.Texture = atlas.Texture; + offBackground.TextureAddress = TextureAddress.Custom; + offBackground.TextureHeight = offBackgroundRegion.Height; + offBackground.TextureLeft = offBackgroundRegion.SourceRectangle.Left; + offBackground.TextureTop = offBackgroundRegion.SourceRectangle.Top; + offBackground.TextureWidth = offBackgroundRegion.Width; + offBackground.Width = 28f; + offBackground.WidthUnits = DimensionUnitType.Absolute; + offBackground.Dock(Gum.Wireframe.Dock.Left); + innerContainer.AddChild(offBackground); + + TextureRegion middleBackgroundRegion = atlas.GetRegion("slider-middle-background"); + + // Create the middle track portion of the slider + NineSliceRuntime middleBackground = new NineSliceRuntime(); + middleBackground.Dock(Gum.Wireframe.Dock.FillVertically); + middleBackground.Texture = middleBackgroundRegion.Texture; + middleBackground.TextureAddress = TextureAddress.Custom; + middleBackground.TextureHeight = middleBackgroundRegion.Height; + middleBackground.TextureLeft = middleBackgroundRegion.SourceRectangle.Left; + middleBackground.TextureTop = middleBackgroundRegion.SourceRectangle.Top; + middleBackground.TextureWidth = middleBackgroundRegion.Width; + middleBackground.Width = 179f; + middleBackground.WidthUnits = DimensionUnitType.Absolute; + middleBackground.Dock(Gum.Wireframe.Dock.Left); + middleBackground.X = 27f; + innerContainer.AddChild(middleBackground); + + TextureRegion maxBackgroundRegion = atlas.GetRegion("slider-max-background"); + + // Create the "MAX" side of the slider (right end) + NineSliceRuntime maxBackground = new NineSliceRuntime(); + maxBackground.Texture = maxBackgroundRegion.Texture; + maxBackground.TextureAddress = TextureAddress.Custom; + maxBackground.TextureHeight = maxBackgroundRegion.Height; + maxBackground.TextureLeft = maxBackgroundRegion.SourceRectangle.Left; + maxBackground.TextureTop = maxBackgroundRegion.SourceRectangle.Top; + maxBackground.TextureWidth = maxBackgroundRegion.Width; + maxBackground.Width = 36f; + maxBackground.WidthUnits = DimensionUnitType.Absolute; + maxBackground.Dock(Gum.Wireframe.Dock.Right); + innerContainer.AddChild(maxBackground); + + // Create the interactive track that responds to clicks + // The special name "TrackInstance" is required for Slider functionality + ContainerRuntime trackInstance = new ContainerRuntime(); + trackInstance.Name = "TrackInstance"; + trackInstance.Dock(Gum.Wireframe.Dock.Fill); + trackInstance.Height = -2f; + trackInstance.Width = -2f; + middleBackground.AddChild(trackInstance); + + // Create the fill rectangle that visually displays the current value + _fillRectangle = new ColoredRectangleRuntime(); + _fillRectangle.Dock(Gum.Wireframe.Dock.Left); + _fillRectangle.Width = 90f; // Default to 90% - will be updated by value changes + _fillRectangle.WidthUnits = DimensionUnitType.PercentageOfParent; + trackInstance.AddChild(_fillRectangle); + + // Add "OFF" text to the left end + TextRuntime offText = new TextRuntime(); + offText.Red = 70; + offText.Green = 86; + offText.Blue = 130; + offText.CustomFontFile = @"fonts/04b_30.fnt"; + offText.FontScale = 0.25f; + offText.UseCustomFont = true; + offText.Text = "OFF"; + offText.Anchor(Gum.Wireframe.Anchor.Center); + offBackground.AddChild(offText); + + // Add "MAX" text to the right end + TextRuntime maxText = new TextRuntime(); + maxText.Red = 70; + maxText.Green = 86; + maxText.Blue = 130; + maxText.CustomFontFile = @"fonts/04b_30.fnt"; + maxText.FontScale = 0.25f; + maxText.UseCustomFont = true; + maxText.Text = "MAX"; + maxText.Anchor(Gum.Wireframe.Anchor.Center); + maxBackground.AddChild(maxText); + + // Define colors for focused and unfocused states + Color focusedColor = Color.White; + Color unfocusedColor = Color.Gray; + + // Create slider state category - Slider.SliderCategoryName is the required name + StateSaveCategory sliderCategory = new StateSaveCategory(); + sliderCategory.Name = Slider.SliderCategoryName; + topLevelContainer.AddCategory(sliderCategory); + + // Create the enabled (default/unfocused) state + StateSave enabled = new StateSave(); + enabled.Name = FrameworkElement.EnabledStateName; + enabled.Apply = () => + { + // When enabled but not focused, use gray coloring for all elements + background.Color = unfocusedColor; + _textInstance.Color = unfocusedColor; + offBackground.Color = unfocusedColor; + middleBackground.Color = unfocusedColor; + maxBackground.Color = unfocusedColor; + _fillRectangle.Color = unfocusedColor; + }; + sliderCategory.States.Add(enabled); + + // Create the focused state + StateSave focused = new StateSave(); + focused.Name = FrameworkElement.FocusedStateName; + focused.Apply = () => + { + // When focused, use white coloring for all elements + background.Color = focusedColor; + _textInstance.Color = focusedColor; + offBackground.Color = focusedColor; + middleBackground.Color = focusedColor; + maxBackground.Color = focusedColor; + _fillRectangle.Color = focusedColor; + }; + sliderCategory.States.Add(focused); + + // Create the highlighted+focused state by cloning the focused state + StateSave highlightedFocused = focused.Clone(); + highlightedFocused.Name = FrameworkElement.HighlightedFocusedStateName; + sliderCategory.States.Add(highlightedFocused); + + // Create the highlighted state by cloning the enabled state + StateSave highlighted = enabled.Clone(); + highlighted.Name = FrameworkElement.HighlightedStateName; + sliderCategory.States.Add(highlighted); + + // Assign the configured container as this slider's visual + Visual = topLevelContainer; + + // Enable click-to-point functionality for the slider + // This allows users to click anywhere on the track to jump to that value + IsMoveToPointEnabled = true; + + // Add event handlers + Visual.RollOn += HandleRollOn; + ValueChanged += HandleValueChanged; + ValueChangedByUi += HandleValueChangedByUi; + } + + /// + /// Automatically focuses the slider when the user interacts with it + /// + private void HandleValueChangedByUi(object sender, EventArgs e) + { + IsFocused = true; + } + + /// + /// Automatically focuses the slider when the mouse hovers over it + /// + private void HandleRollOn(object sender, EventArgs e) + { + IsFocused = true; + } + + /// + /// Updates the fill rectangle width to visually represent the current value + /// + private void HandleValueChanged(object sender, EventArgs e) + { + // Calculate the ratio of the current value within its range + double ratio = (Value - Minimum) / (Maximum - Minimum); + + // Update the fill rectangle width as a percentage + // _fillRectangle uses percentage width units, so we multiply by 100 + _fillRectangle.Width = 100 * (float)ratio; + } +} diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/textureregion/properties.cs b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/textureregion/properties.cs new file mode 100644 index 00000000..69bd9fdd --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/textureregion/properties.cs @@ -0,0 +1,19 @@ +/// +/// Gets the top normalized texture coordinate of this region. +/// +public float TopTextureCoordinate => SourceRectangle.Top / (float)Texture.Height; + +/// +/// Gets the bottom normalized texture coordinate of this region. +/// +public float BottomTextureCoordinate => SourceRectangle.Bottom / (float)Texture.Height; + +/// +/// Gets the left normalized texture coordinate of this region. +/// +public float LeftTextureCoordinate => SourceRectangle.Left / (float)Texture.Width; + +/// +/// Gets the right normalized texture coordinate of this region. +/// +public float RightTextureCoordinate => SourceRectangle.Right / (float)Texture.Width; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/tilemap-definition.xml b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/tilemap-definition.xml new file mode 100644 index 00000000..ad6e5157 --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/tilemap-definition.xml @@ -0,0 +1,15 @@ + + + images/atlas + + 00 01 02 01 02 01 02 01 02 01 02 01 02 01 02 03 + 04 05 05 06 05 05 06 05 05 06 05 05 06 05 05 07 + 08 09 09 09 09 09 09 09 09 09 09 09 09 09 09 11 + 04 09 09 09 09 09 09 09 10 09 09 09 09 10 09 07 + 08 09 10 09 09 09 09 09 09 09 09 09 09 09 09 11 + 04 09 09 09 09 09 09 09 09 09 09 09 09 09 09 07 + 08 10 09 09 09 09 09 09 09 09 10 09 09 09 09 11 + 04 09 09 09 09 09 10 09 09 09 09 09 09 09 09 07 + 12 13 14 13 14 13 14 13 14 13 14 13 14 13 14 15 + + \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/titlescene/createoptionspanel.cs b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/titlescene/createoptionspanel.cs new file mode 100644 index 00000000..e1fb37e1 --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/titlescene/createoptionspanel.cs @@ -0,0 +1,52 @@ +private void CreateOptionsPanel() +{ + _optionsPanel = new Panel(); + _optionsPanel.Dock(Gum.Wireframe.Dock.Fill); + _optionsPanel.IsVisible = false; + _optionsPanel.AddToRoot(); + + TextRuntime optionsText = new TextRuntime(); + optionsText.X = 10; + optionsText.Y = 10; + optionsText.Text = "OPTIONS"; + optionsText.UseCustomFont = true; + optionsText.FontScale = 0.5f; + optionsText.CustomFontFile = @"fonts/04b_30.fnt"; + _optionsPanel.AddChild(optionsText); + + OptionsSlider musicSlider = new OptionsSlider(_atlas); + musicSlider.Name = "MusicSlider"; + musicSlider.Text = "MUSIC"; + musicSlider.Anchor(Gum.Wireframe.Anchor.Top); + musicSlider.Visual.Y = 30f; + musicSlider.Minimum = 0; + musicSlider.Maximum = 1; + musicSlider.Value = Core.Audio.SongVolume; + musicSlider.SmallChange = .1; + musicSlider.LargeChange = .2; + musicSlider.ValueChanged += HandleMusicSliderValueChanged; + musicSlider.ValueChangeCompleted += HandleMusicSliderValueChangeCompleted; + _optionsPanel.AddChild(musicSlider); + + OptionsSlider sfxSlider = new OptionsSlider(_atlas); + sfxSlider.Name = "SfxSlider"; + sfxSlider.Text = "SFX"; + sfxSlider.Anchor(Gum.Wireframe.Anchor.Top); + sfxSlider.Visual.Y = 93; + sfxSlider.Minimum = 0; + sfxSlider.Maximum = 1; + sfxSlider.Value = Core.Audio.SoundEffectVolume; + sfxSlider.SmallChange = .1; + sfxSlider.LargeChange = .2; + sfxSlider.ValueChanged += HandleSfxSliderChanged; + sfxSlider.ValueChangeCompleted += HandleSfxSliderChangeCompleted; + _optionsPanel.AddChild(sfxSlider); + + _optionsBackButton = new AnimatedButton(_atlas); + _optionsBackButton.Text = "BACK"; + _optionsBackButton.Anchor(Gum.Wireframe.Anchor.BottomRight); + _optionsBackButton.X = -28f; + _optionsBackButton.Y = -10f; + _optionsBackButton.Click += HandleOptionsButtonBack; + _optionsPanel.AddChild(_optionsBackButton); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/titlescene/createtitlepanel.cs b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/titlescene/createtitlepanel.cs new file mode 100644 index 00000000..9da0c777 --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/titlescene/createtitlepanel.cs @@ -0,0 +1,25 @@ +private void CreateTitlePanel() +{ + // Create a container to hold all of our buttons + _titleScreenButtonsPanel = new Panel(); + _titleScreenButtonsPanel.Dock(Gum.Wireframe.Dock.Fill); + _titleScreenButtonsPanel.AddToRoot(); + + AnimatedButton startButton = new AnimatedButton(_atlas); + startButton.Anchor(Gum.Wireframe.Anchor.BottomLeft); + startButton.Visual.X = 50; + startButton.Visual.Y = -12; + startButton.Text = "Start"; + startButton.Click += HandleStartClicked; + _titleScreenButtonsPanel.AddChild(startButton); + + _optionsButton = new AnimatedButton(_atlas); + _optionsButton.Anchor(Gum.Wireframe.Anchor.BottomRight); + _optionsButton.Visual.X = -50; + _optionsButton.Visual.Y = -12; + _optionsButton.Text = "Options"; + _optionsButton.Click += HandleOptionsClicked; + _titleScreenButtonsPanel.AddChild(_optionsButton); + + startButton.IsFocused = true; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/titlescene/fields.cs b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/titlescene/fields.cs new file mode 100644 index 00000000..ce9927dd --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/titlescene/fields.cs @@ -0,0 +1,11 @@ +// Existing fields... + +// The options button used to open the options menu. +private AnimatedButton _optionsButton; + +// The back button used to exit the options menu back to the title menu. +private AnimatedButton _optionsBackButton; + +// Reference to the texture atlas that we can pass to UI elements when they +// are created. +private TextureAtlas _atlas; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/titlescene/loadcontent.cs b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/titlescene/loadcontent.cs new file mode 100644 index 00000000..890f2334 --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/titlescene/loadcontent.cs @@ -0,0 +1,17 @@ +public override void LoadContent() +{ + // Load the font for the standard text. + _font = Core.Content.Load("fonts/04B_30"); + + // Load the font for the title text + _font5x = Content.Load("fonts/04B_30_5x"); + + // Load the background pattern texture. + _backgroundPattern = Content.Load("images/background-pattern"); + + // Load the sound effect to play when ui actions occur. + _uiSoundEffect = Core.Content.Load("audio/ui"); + + // Load the texture atlas from the xml configuration file. + _atlas = TextureAtlas.FromFile(Core.Content, "images/atlas-definition.xml"); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/titlescene/usings.cs b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/titlescene/usings.cs new file mode 100644 index 00000000..2afa4bad --- /dev/null +++ b/articles/tutorials/building_2d_games/21_customizing_gum_ui/snippets/titlescene/usings.cs @@ -0,0 +1,12 @@ +using System; +using DungeonSlime.UI; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameGum; +using MonoGameGum.Forms.Controls; +using MonoGameGum.GueDeriving; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; +using MonoGameLibrary.Scenes; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/21_customizing_gum_ui/videos/gameplay.webm b/articles/tutorials/building_2d_games/21_customizing_gum_ui/videos/gameplay.webm new file mode 100644 index 00000000..a0587164 Binary files /dev/null and b/articles/tutorials/building_2d_games/21_customizing_gum_ui/videos/gameplay.webm differ diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_copy_head.png b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_copy_head.png new file mode 100644 index 00000000..7d4dd017 Binary files /dev/null and b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_copy_head.png differ diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_copy_head.svg b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_copy_head.svg new file mode 100644 index 00000000..73454a6d --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_copy_head.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_directions.png b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_directions.png new file mode 100644 index 00000000..a91550d6 Binary files /dev/null and b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_directions.png differ diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_directions.svg b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_directions.svg new file mode 100644 index 00000000..321ce18c --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_directions.svg @@ -0,0 +1,180 @@ + + diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_insert_head.png b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_insert_head.png new file mode 100644 index 00000000..c6528141 Binary files /dev/null and b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_insert_head.png differ diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_insert_head.svg b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_insert_head.svg new file mode 100644 index 00000000..2b99c430 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_insert_head.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_remove_tail.png b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_remove_tail.png new file mode 100644 index 00000000..0b996de3 Binary files /dev/null and b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_remove_tail.png differ diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_remove_tail.svg b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_remove_tail.svg new file mode 100644 index 00000000..a8c48909 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/images/snake_remove_tail.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/index.md b/articles/tutorials/building_2d_games/22_snake_game_mechanics/index.md new file mode 100644 index 00000000..7aeee8b2 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/index.md @@ -0,0 +1,489 @@ +--- +title: "Chapter 22: Snake Game Mechanics" +description: "Learn how to implement classic snake-like game mechanics and organize game objects into reusable components." +--- + +In the previous chapters, we have built all the fundamental systems needed for our game: [graphics](../07_optimizing_texture_rendering/index.md), [input](../11_input_management/index.md), [collision detection](../12_collision_detection/index.md), [audio](../15_audio_controller/index.md), [scene management](../17_scenes/index.md), and a [user interface](../19_user_interface_fundamentals/index.md). Now it is time to transform our demo into a complete experience by implementing classic snake-like game mechanics. Before we do that, we first need to define what mechanics make a snake game. + +In this chapter, you will: + +- Understand the core mechanics that define a classic snake-like game. +- Learn how to implement grid-based movement with timed intervals. +- Create a segmented character that grows when collecting objects. +- Implement a unified input controller for game actions. +- Build the `SlimeSegment` struct for storing segment data. +- Create the `Slime` class to manage all snake-like behavior for the slime. +- Create the `Bat` class as the collectable object. + +> [!NOTE] +> This chapter will not focus much on MonoGame itself, but rather our implementation of the mechanics to transform our current game into a snake-like game. + +## Understanding Snake Game Mechanics + +In a classic snake-like game, the mechanics follow a set of simple but engaging rules: + +1. The player controls a snake by telling it to move in one of four cardinal directions (up, down, left, and right). +2. The snake cannot reverse into itself, only moving forward or perpendicular to its current direction. +3. The actual movement of the snake occurs at regular timed intervals, creating a grid-based movement pattern. +4. When the snake eats food, it grows longer by adding a new segment to its tail. +5. If the snake collides with a wall or its own body, the game ends. + +The mechanics create an increasingly challenging experience as the snake grows longer, requiring planning and reflexes to avoid collision. + +### Directions + +In snake, players input a cardinal direction (up, down, left, and right), to indicate which direction the snake will move during the next movement cycle.  When direction input occurs, it must be checked against the current direction to determine if the move is valid. + +For example, if the snake is moving to the right, an invalid input would allow a player to move it to the left.  Doing so would cause the head of the snake to reverse direction and immediately collide with the first body segment. This means the only valid inputs are those where the next direction would be the same as the current direction or perpendicular to the current direction. + +| ![Figure 22-1: An example snake with four segments, the head segment highlighted in orange, moving to the right. Arrows show that the only valid movements for the head segment are up or down (perpendicular) or to continue to the right.](./images/snake_directions.png) | +| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 22-1: An example snake with four segments, the head segment highlighted in orange, moving to the right. Arrows show that the only valid movements for the head segment are up or down (perpendicular) or to continue to the right.** | + +### Movement Cycle + +Instead of moving every update frame as a directional input is being pressed, the snake instead only moves during regular timed intervals.  A timer is used to determine how much time has passed since the last movement cycle, and when it reaches a set threshold, the next movement cycle occurs.  During this movement cycle, the snake should move forward in the direction that was input by the player between the last and current movement cycles.  This creates the grid-based movement system typically found in snake-like games. + +There are various methods for handling the movement, such as iterating through each segment of the snake and updating the position of that segment to move forward.  Methods such as this though are wasteful, since visually the only parts of the snake that move on the screen are the head and the tail.   + +Instead, a more common approach is to: + +1. Make a copy of the head segment. + + | ![Figure 22-2: From a snake with four segments, a copy of the head segment is made, represented by the orange block](./images/snake_copy_head.png) | + | :------------------------------------------------------------------------------------------------------------------------------------------------: | + | **Figure 22-2 From a snake with four segments, a copy of the head segment is made, represented by the orange block** | + +2. Update the properties of the copy so that it is positioned where the original head segment would have moved to. +3. Insert the copy at the front of the segment collection. + + | ![Figure 22-3: The copy of the head segment, represented by the orange block, is inserted at the front of the segment collection as the new head, which now makes it five segments (one too many)](./images/snake_insert_head.png) | + | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | + | **Figure 22-3: The copy of the head segment, represented by the orange block, is inserted at the front of the segment collection as the new head, which now makes it five segments (one too many)** | + +4. Remove the tail segment. + + | ![Figure 22-4: The tail segment of the snake is removed, bringing it back to the original four segments, giving the illusion that the entire snake moved forward ](./images/snake_remove_tail.png) | + | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | + | **Figure 22-4: The tail segment of the snake is removed, bringing it back to the original four segments, giving the illusion that the entire snake moved forward** | + +By doing this, no matter how many segments the snake body has, we only ever need to update two of them, the head and the tail. + +### Growth + +The next core mechanic for a snake-like game is the growth of the snake.  Typically, somewhere in the game is an objective for the snake to eat, such as an apple.  When the head of the snake reaches this objective, a new segment is added to the snake's body, making it longer, the player's score is increased, and a new objective is spawned at a random location within the game. + +This mechanic also acts as an artificial difficulty curve for the game.  As the body of the snake grows longer, it requires planning to avoid creating situations where the snake becomes trapped by its own body making it impossible to avoid a collision. + +### Game Over + +The challenge in a snake-like game is to avoid colliding with either a wall or another segment of the snake body.  Doing so will cause a game over condition as the snake can no longer continue moving forward. + +## Implementing Snake-Like Mechanics In Our Game + +Now that we have a foundational set of rules in place for the mechanics of a snake-like game, we will implement them into the current demo we have been building up. Our game will adapt these mechanics to fit them into our existing game structure: + +- Instead of a snake, we will use the slime and create a chain of slime segments that follow the slime at the front. +- The player will control the direction of the slime segment +- The bat will serve as the objective to acquire to grow the slime segment. +- Collisions with either the walls of the room or a slime segment will result in a game over state. + +As we implement these mechanics, we are also going to be creating classes that encapsulate the properties and functions of existing implementations in the game scene, such as the slime and the bat.  For example, currently, the game scene tracks fields for the `AnimatedSprite` and the position of the slime, as well as updating, input handling, and drawing the slime.  These can be moved into their dedicated classes encapsulating the functionality and also cleaning up the code in the game scene that has grown quite large. + +### The GameController Class + +Currently, we have two methods dedicated to handling input in the game scene, `CheckKeyboardInput` and `CheckGamePadInput`, both of these methods essentially perform the same logic across different input devices. This presents an opportunity to improve our code. + +To simplify input handling for the game, we can create a dedicated class that consolidates the input methods, providing a unified input profile for the game.  This pattern is widely used in game development to separate the "what" (game actions) from the "how" (specific input devices and buttons). + +Create a new file named `GameController.cs` in the root of the `DungeonSlime` project (your main game project) and add the following code: + +[!code-csharp[](./snippets/gamecontroller.cs)] + +The `GameController` class implements an important design pattern in game development known as **"Input Abstraction"** or the **"Command"** pattern.  This pattern separates what happens in the game (the actions) from how players trigger those actions (the inputs). + +This separation provides several benefits, including: + +1. **Input Device Independence**: The game logic does not need to know which input device the player is using. Whether they are playing with a keyboard, gamepad, or touch screen, the game only cares that a "move up" action was triggered, not which specific button or key caused it. +2. **Simplified Input Handling**: Instead of checking multiple input combinations throughout the codebase, game objects can simply ask "Should I move up?" through a clean API call. +3. **Easy Rebinding**: If you want to add key rebinding features, you only need to modify the `GameController` class, not every piece of code that uses input. +4. **Consistent Input Logic**: The rules for determining if an action occurred (like checking if a button was just pressed version being held down) are defined in one place. +5. **Cross-Platform Compatibility**: When porting to different platforms with different input methods, you only need to update the `GameController` class to map the new input devices to your existing game actions. + +By implementing this pattern in our game, we are not only making our current input handling cleaner, but we are also establishing a foundation that would make it easier to add features like input customization or support for new input devices in the future. + +With our input handling system in place, now we can turn our attention to implementing the core mechanics of our snake-like game. First, we need to create a structure that will represent each segment of the slime's body. + +### The SlimeSegment Struct + +We will need to implement a structure that can represent each segment of the slime, this structure will store the position and movement data for each segment. + +In the *DungeonSlime* project (your main game project), create a new directory named `GameObjects`. We will be putting all of our code related to the objects within the game here. +Then create a new file named `SlimeSegment.cs` inside the `GameObjects` directory you just created and add the following code: + +[!code-csharp[](./snippets/slimesegment.cs)] + +This structure contains fields to track: + +- `At`: The current position of the segment. +- `To`: The position the segment will move to during the next movement cycle if it is the head segment. +- `Direction`: A normalized vector representing the direction the segment is moving in. +- `ReverseDirection`: A computed property that returns the opposite of the `Direction` property. + +> [!NOTE] +> We are implementing this as a struct rather than a class because SlimeSegment is a small, simple data container with value semantics. Structs are more efficient for small data structures since they are allocated on the [stack rather than the heap](https://learn.microsoft.com/en-us/dotnet/standard/automatic-memory-management), reducing garbage collection overhead. Since our game will potentially create many segments as the snake grows, using a struct can provide better performance, especially when we will be copying segment data during movement operations. + +> [!IMPORTANT] +> Structs work best with value types (like int, float, [**Vector2**](xref:Microsoft.Xna.Framework.Vector2)); using reference types in structs can cause boxing operations that negate the performance benefits. For more information on structs, refer to the [Structure Types - C# Reference](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/struct) documentation on Microsoft Learn. + +By tracking both the current (`At`) and target (`To`) positions, we can implement smooth visual movement between grid positions, creating more fluid animations than the traditional stutter step movement seen in classic snake games.  We will explore this concept a little further in this chapter. + +### The Slime Class + +Next, we can implement a class to encapsulate the properties and functionality of our snake-like slime. + +In the `GameObjects` directory of the *DungeonSlime* project (your main game project), create a new file named `Slime.cs` and add the following initial code: + +[!code-csharp[](./snippets/slime/definition.cs)] + +This code sets up the basic structure for our `Slime` class. We have added the necessary using statements to access MonoGame's framework components and placed the class in the `DungeonSlime.GameObjects` namespace to keep our code organized. The empty class will serve as our foundation, and we will build it up piece by piece in the following sections. + +Each section below should be added to the `Slime` class in the order presented. As we go through each part, the class will gradually take shape to handle all the snake-like behavior we need. + +> [!NOTE] +> When adding these sections one by one, you may see compiler errors until all sections are in place. This is normal, as some parts of the code will reference fields or methods that have not been added yet. Once all sections are complete, these errors will resolve. + +#### Slime Fields + +Add the following fields to the `Slime` class: + +[!code-csharp[](./snippets/slime/fields.cs)] + +Each of these fields is responsible for: + +- `s_movementTime`: This constant represents how long the slime waits between movement cycles (300ms). This creates the classic snake game's grid-based movement feel, where the snake moves at regular intervals rather than continuously. +- `_movementTime`: This field accumulates elapsed time until it reaches the movement threshold. When it does, the slime moves one grid cell and the timer resets. +- `_movementProgress`: This normalized value (0-1) represents progress between movement ticks and is used for visual interpolation. It allows us to smoothly animate the slime's movement between grid positions. +- `_nextDirection`: This stores the direction that will be applied to the head segment during the next movement cycle. +- `_stride`: This represents the total number of pixels the head segment should move during movement cycles. +- `_segments`: This collection holds all the `SlimeSegment` structures that make up the slime's body. The first segment is the head, and the rest form the trailing body. +- `_sprite`: This stores the `AnimatedSprite` that is used to draw each segment of the slime. + +These fields implement core snake-like mechanics - the timed interval movement, direction control, and the segmented body that forms the snake. + +#### Slime Events + +Next, add the following event to the `Slime` class after the fields: + +[!code-csharp[](./snippets/slime/events.cs)] + +This event will allow the `Slime` class to notify the game scene when the head of the slime collides with another segment, triggering a game over. + +#### Slime Constructor + +After the event, add the following constructor to the `Slime` class: + +[!code-csharp[](./snippets/slime/constructor.cs)] + +This is a simple constructor that requires the slime to be given the `AnimatedSprite` that will be used to draw each of the slime segments. + +#### Slime Initialization + +Add the following `Initialization` method to the `Slime` class after the constructor: + +[!code-csharp[](./snippets/slime/initialize.cs)] + +With this method, we can initialize, or reset the state of slime.  It: + +- Instantiates a new segment collection. +- Creates the initial head segment and positions it at the specific `startingPosition`. +- Sets the initial direction to be to the right. +- Initialize the movement timer to zero. + +#### Slime Input Handling + +Next, add the `HandleInput` method to process player input after the `Initialize` method: + +[!code-csharp[](./snippets/slime/handleinput.cs)] + +This method implements the following: + +1. Determine if the player is attempting to change directions instead of directly moving the slime.  This direction change will be applied later during the movement cycle update. +2. Uses [**Vector2.Dot**](xref:Microsoft.Xna.Framework.Vector2.Dot(Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Vector2)) to prevent the slime from reversing into itself, causing an immediate collision and game over state. +3. Updates the `_nextDirection` value only if the direction input is valid. + +> [!NOTE] +> The dot product measures how much two vectors point in the same direction. It is: +> +> - Positive if they are pointing in the same direction. +> - Negative if they are pointing in opposite directions. +> - Zero when they are perpendicular. +> +> By using dot product here, this effectively implements the classic snake-like game rule that you cannot turn 180° into yourself. + +#### Slime Movement Cycle + +To handle the snake-like movement cycle of the slime, we will create a method called `Move`. + +Add the following method to the `Slime` class after the `HandleInput` method: + +[!code-csharp[](./snippets/slime/move.cs)] + +This method performs the core snake-like movement cycle logic by: + +1. Copying the value of the current head segment. +2. Updating the copy's position (`At`) to where the head was moving to and updating the position it is moving to (`To`). +3. Insert the copy into the front of the segment collection and remove the tail. +4. Check if the head is now in the same position as any body segments, which would cause a collision and trigger a game over. + +> [!NOTE] +> By inserting a new head segment at the front of the chain and removing the last segment, this creates the illusion of the entire chain moving forward as one, even though we are only actually moving the head forward and removing the tail. +> +> This follows the common snake movement pattern as discussed in the [Understanding Snake Game Mechanics: Movement Cycle](#movement-cycle) section above. + +#### Slime Growth + +To handle the snake-like growth of the slime, we will create a new method called `Grow`. + +Add the following method to the `Slime` class after the `Move` method: + +[!code-csharp[](./snippets/slime/grow.cs)] + +Th `Grow` method works as follows: + +1. First it creates a copy of the current tail value. +2. It then adjusts the values of the copy so that it is now positioned behind the current tail by using the `ReverseDirection` value of the tail. +3. Finally, it inserts this new tail into the segments collection. + +#### Slime Update + +With most of the core snake-like mechanics now added to the `Slime` class within their own methods we can now work on what happens while the slime is operating. + +Add the following `Update` method to the `Slime` class after the `Grow` method: + +[!code-csharp[](./snippets/slime/update.cs)] + +This update method: + +1. Updates the slime's `AnimatedSprite` to ensure the sprite animations occur. +2. Calls `HandleInput` to check for player input. +3. Increments the movement timer by the amount of time that has elapsed between the game's update cycles. +4. Performs a check to see if the movement timer has accumulated more time than the threshold to perform a movement cycle update.  If it has then: +   1. The movement timer is reduced by the threshold time. +   2. The `Move` method is called to perform a movement cycle update. +5. Finally, the movement progress amount is calculated by dividing the number of seconds accumulated for the movement timer by the number of seconds for the threshold.  This gives us a normalized value between 0.0 and 1.0 that we can use for visual interpolation for fluid movement. + +> [!TIP] +> In games, frame rates can vary based on system performance, causing inconsistent update intervals.  If we simply reset the movement timer to zero after each movement cycle, we would lose any excess time that accumulated beyond the movement threshold. +> +> For example: +> +> - Our movement threshold is 200ms. +> - The game runs at 60fps (16.67ms per frame). +> - After 12 frames, we have accumulated 200.04ms. +> - If we reset to zero, we lose 0.04ms. +> - Over time, these small losses can add up and cause inconsistent movement. +> +> By subtracting the threshold instead of resetting to zero, we "bank" the excess time (0.06ms in this example) for the next movement cycle.  This ensures that: +> +> 1. Movement happens exactly at the intended frequency, maintaining consistent game speed. +> 2. The visual smoothness of movement remains intact even if the game occasionally drops frames. +> 3. Players experience the same game timing regardless of their hardware's performance. +> +> This technique is standard practice in game development, especially for timing-sensitive mechanics like rhythmic games, animations, and movement systems.  It is a simple solution that significantly improves gameplay consistency. + +#### Slime Draw + +We also need a method to handle drawing the slime and all of its segments. + +Add the following `Draw` method after the `Update` method to the `Slime` class: + +[!code-csharp[](./snippets/slime/draw.cs)] + +This draw method iterates each segment of the slime and calculates the visual position to draw each segment at by performing [linear interpolation (lerp)](https://www.corykoseck.com/2018/08/29/programming-in-c-lerp/) to determine the position of the segment between its current position (`At`) and the position it is moving to (`To`) based on the `_movementProgress` calculation. + +> [!NOTE] +> [**Vector2.Lerp**](xref:Microsoft.Xna.Framework.Vector2.Lerp(Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Vector2,System.Single)) performs a linear interpolation between two vectors, creating a smooth transition from start to end based on an amount parameter.  The formula is: +> +> $\text{result} = \text{start} + (\text{end} - \text{start}) \cdot \text{amount}$ +> +> Where the amount parameter ranges from 0.0 (returns the start position) to 1.0 (returns the end position).  Values between 0.0 and 1.0 give positions along the straight line between start and end. +> +> In our game, this is used to create a visual fluid movement illusion by interpolating between a segment's current position (`At`) and its target position (`To`) based on the elapsed time, rather than having the segments abruptly jump from one grid position to the next after each movement update. + +#### Slime Bounds + +For the game scene to detect collisions between the slime and other elements (walls or the bat), we need a method to calculate the current collision bounds. + +Add the following method to the `Slime` class after the `Draw` method: + +[!code-csharp[](./snippets/slime/getbounds.cs)] + +This method takes the current head segment (the first segment in our collection), calculates its visual position using linear interpolation, and then creates a `Circle` value to represent its collision boundary. Using the interpolated position ensures that collision detection aligns with what the player sees on screen. + +> [!NOTE] +> We only need collision bounds for the slime's head for interactions with the bat and walls, as this matches the classic snake game mechanic where only the head's collisions matter for gameplay. For detecting collisions between the head and body segments, we use a simpler position-based check in the `Move` method since those positions are always aligned to the grid. + +With all these methods in place, our Slime class now fully implements the snake-like mechanics we need. + +It handles: + +- Movement on a grid. +- Prevents invalid direction changes +- Detects self-collisions +- Provides smooth visual movement between grid positions. + +This encapsulation allows us to manage all slime-related behavior in one place while exposing only the necessary interfaces to the game scene. + +Now that we have our player-controlled character implemented, we can create the object that the slime will try to collect; the bat. + +### The Bat Class + +In the `GameObjects` directory of the *DungeonSlime* project (your main game project), create a new file named `Bat.cs` and add the following initial code: + +[!code-csharp[](./snippets/bat/definition.cs)] + +This code establishes the foundation for our `Bat` class. We have included the necessary using statements for MonoGame components, audio functionality, and our library references. The class is placed in the same `DungeonSlime.GameObjects` namespace as our Slime class to maintain a consistent organization. + +Now we will build this class step by step, adding all the functionality needed for the bat to serve as the collectible object in our game. Add each of the following sections to the `Bat` class in the order they are presented. + +> [!NOTE] +> As with the Slime class, you may encounter compiler errors until all sections are in place. These errors will be resolved once all components of the class have been added. + +#### Bat Fields + +Add the following fields to the `Bat` class: + +[!code-csharp[](./snippets/bat/fields.cs)] + +Each of these fields is responsible for: + +- `MOVEMENT_SPEED`: This constant represents the factor to multiply the velocity vector by to determine how fast the bat is moving. +- `_velocity`: A vector that defines the direction and how much in that direction to update the position of the bat each update cycle. +- `_sprite`: This stores the `AnimatedSprite` that is used to draw the bat. +- `_bounceSoundEffect`: This store the [**SoundEffect**](xref:Microsoft.Xna.Framework.Audio.SoundEffect) to play when the bat is told to bounce. + +#### Bat Properties + +Next, add the following property to the `Bat` class after the fields: + +[!code-csharp[](./snippets/bat/properties.cs)] + +This property exposes the position of the bat so it can be used for calculations in the game scene when determining where to place the bat after the slime eats it. + +#### Bat Constructor + +After the property, add the following constructor to the `Bat` class: + +[!code-csharp[](./snippets/bat/constructor.cs)] + +This is a simple constructor that requires the bat to be given the `AnimatedSprite` that will be used to draw the bat and the [**SoundEffect**](xref:Microsoft.Xna.Framework.Audio.SoundEffect) to be played when the bat bounces off a wall. + +#### Bat Randomize Velocity + +Currently, we have the `AssignRandomVelocity` method in the `GameScene` that we call to randomize the velocity of the bat after it has been eaten by the slime. We can take this method out of the `GameScene` class and put it directly into the `Bat` class itself. + +Add the following method to the `Bat` class after the constructor: + +[!code-csharp[](./snippets/bat/randomizevelocity.cs)] + +#### Bat Bounce + +We are also going to take the logic from the `GameScene` class that bounces the bat off the walls and move it into a dedicated method in the `Bat` class. + +Add the following method to the `Bat` class after the `RandomizeVelocity` method: + +[!code-csharp[](./snippets/bat/bounce.cs)] + +This method only takes a single parameter, the [normal vector](../12_collision_detection/index.md#bounce-collision-response) of the surface the bat is bouncing against.  Based on the X and Y components of the normal vector, we can determine which wall the bat bounced against and adjust the position of the bat so that it does not stick to the wall. + +#### Bat Bounds + +Similar to the [`Slime` class](#slime-bounds), for the game scene to detect collision between the bat and other elements, we need a method to calculate the current collision bounds of the bat. + +Add the following method to the `Bat` class after the `Bounce` method: + +[!code-csharp[](./snippets/bat/getbounds.cs)] + +#### Bat Update + +The `Bat` class will also need to be updated. + +Add the following `Update` method to the `Bat` class after the `GetBounds` method: + +[!code-csharp[](./snippets/bat/update.cs)] + +This method simply updates the bat's `AnimatedSprite` to ensure animations occur and adjusts the position of the bat based on the current velocity. + +> [!NOTE] +> The continuous movement of the bat contrasts with the grid-based interval movement of the slime, creating different gameplay dynamics for the player to consider.  This makes catching the bat challenging without requiring any complex behaviors. + +#### Bat Draw + +Finally, we need a method to draw the bat. + +Add the following `Draw` method to the `Bat` class after the `Update` method: + +[!code-csharp[](./snippets/bat/draw.cs)] + +This method simply draws the bat's `AnimatedSprite` at the bat's current position. + +With the `Bat` class complete, we have now encapsulated all the behavior needed for the collectible element in our game. The bat moves continuously around the screen and can bounce off walls, adding a twist on the classic snake-like mechanic by creating a target for the player to chase. + +## Conclusion + +> [!NOTE] +> To the observant, you should notice that the main game screen has not been updated and therefore nothing has changed if we run the game at this point. In the next chapter we will finalize the gameplay. + +In this chapter, we have learned about and implemented the core mechanics of a class snake-like game. We created: + +- A [`GameController`](#the-gamecontroller-class) class that provides a unified input interface, separating game actions from specific input devices. +- A [`SlimeSegment`](#the-slimesegment-struct) struct to efficiently store and manage individual segments of our snake-like character. +- A [`Slime`](#the-slime-class) class that implements grid-based movement, segment management and self-collision detection. +- A [`Bat`](#the-bat-class) class that serves as the collectible object with continuous movement and wall bouncing. + +These implementations encapsulate the core gameplay mechanics into reusable, maintainable objects. + +In the next chapter, we will build on these mechanics by updating the `GameScene` to implement game state management and a new UI element for the game over state to create a complete game experience. + +## Test Your Knowledge + +1. Why must a snake-like game prevent the player from reversing direction? + + :::question-answer + Preventing reverse movement is necessary because it would cause an immediate collision between the snake's head and the first body segment, resulting in an unfair game over. + ::: + +2. How does the movement cycle for a snake work, and why is it more efficient than updating each segment individually? + + :::question-answer + The snake movement cycle works by: + + 1. Creating a copy of the head segment. + 2. Positioning the copy one grid cell ahead in the current direction + 3. Inserting this copy at the front of the segment collection + 4. Removing the last segment. + + This approach is more efficient because it only requires manipulating two segments (adding a new head and removing the tail) regardless of how long the snake becomes, rather than iterating through and updating every segment individually. + ::: + +3. What are the benefits of using the Input Abstraction pattern implemented in the `GameController` class? + + :::question-answer + The Input Abstraction pattern provides several benefits: + + - Input device independence, allowing the game to handle keyboard, gamepad, or other inputs through a unified interface + - Simplified input handling through clean API calls rather than checking multiple input combinations + - Easier implementation of key rebinding features by only needing to modify the GameController class + - Consistent input logic defined in a single location + - Better cross-platform compatibility by centralizing platform-specific input handling + + ::: + +4. How does the implementation use [**Vector2.Lerp**](xref:Microsoft.Xna.Framework.Vector2.Lerp(Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Vector2,System.Single)) to create smooth visual movement, and why is this important? + + :::question-answer + The implementation uses [**Vector2.Lerp**](xref:Microsoft.Xna.Framework.Vector2.Lerp(Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Vector2,System.Single)) to interpolate between a segment's current position (`At`) and its target position (`To`) based on a normalized movement progress value. This creates smooth visual movement by drawing the segments at intermediate positions between grid points rather than abruptly jumping from one grid position to the next. + + This is important because it provides more fluid animation while maintaining the logical grid-based movement, enhancing the visual quality of the game without changing the core mechanics. + ::: diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/bounce.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/bounce.cs new file mode 100644 index 00000000..87275f52 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/bounce.cs @@ -0,0 +1,32 @@ +/// +/// Handles a bounce event when the bat collides with a wall or boundary. +/// +/// The normal vector of the surface the bat is bouncing against. +public void Bounce(Vector2 normal) +{ + Vector2 newPosition = Position; + + // Adjust the position based on the normal to prevent sticking to walls. + if(normal.X != 0) + { + // We are bouncing off a vertical wall (left/right). + // Move slightly away from the wall in the direction of the normal. + newPosition.X += normal.X * (_sprite.Width * 0.1f); + } + + if(normal.Y != 0) + { + // We are bouncing off a horizontal wall (top/bottom). + // Move slightly way from the wall in the direction of the normal. + newPosition.Y += normal.Y * (_sprite.Height * 0.1f); + } + + // Apply the new position + Position = newPosition; + + // Apply reflection based on the normal. + _velocity = Vector2.Reflect(_velocity, normal); + + // Play the bounce sound effect. + Core.Audio.PlaySoundEffect(_bounceSoundEffect); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/constructor.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/constructor.cs new file mode 100644 index 00000000..1112cbcf --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/constructor.cs @@ -0,0 +1,10 @@ +/// +/// Creates a new Bat using the specified animated sprite and sound effect. +/// +/// The AnimatedSprite ot use when drawing the bat. +/// The sound effect to play when the bat bounces off a wall. +public Bat(AnimatedSprite sprite, SoundEffect bounceSoundEffect) +{ + _sprite = sprite; + _bounceSoundEffect = bounceSoundEffect; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/definition.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/definition.cs new file mode 100644 index 00000000..7bb9721c --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/definition.cs @@ -0,0 +1,12 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; + +namespace DungeonSlime.GameObjects; + +public class Bat +{ + +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/draw.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/draw.cs new file mode 100644 index 00000000..904ded21 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/draw.cs @@ -0,0 +1,7 @@ +/// +/// Draws the bat. +/// +public void Draw() +{ + _sprite.Draw(Core.SpriteBatch, Position); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/fields.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/fields.cs new file mode 100644 index 00000000..3039ab77 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/fields.cs @@ -0,0 +1,11 @@ +private const float MOVEMENT_SPEED = 5.0f; + +// The velocity of the bat that defines the direction and how much in that +// direction to update the bats position each update cycle. +private Vector2 _velocity; + +// The AnimatedSprite used when drawing the bat. +private AnimatedSprite _sprite; + +// The sound effect to play when the bat bounces off the edge of the room. +private SoundEffect _bounceSoundEffect; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/getbounds.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/getbounds.cs new file mode 100644 index 00000000..1cf7f921 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/getbounds.cs @@ -0,0 +1,12 @@ +/// +/// Returns a Circle value that represents collision bounds of the bat. +/// +/// A Circle value. +public Circle GetBounds() +{ + int x = (int)(Position.X + _sprite.Width * 0.5f); + int y = (int)(Position.Y + _sprite.Height * 0.5f); + int radius = (int)(_sprite.Width * 0.25f); + + return new Circle(x, y, radius); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/properties.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/properties.cs new file mode 100644 index 00000000..095e1de9 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/properties.cs @@ -0,0 +1,4 @@ +/// +/// Gets or Sets the position of the bat. +/// +public Vector2 Position { get; set;} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/randomizevelocity.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/randomizevelocity.cs new file mode 100644 index 00000000..1fe80487 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/randomizevelocity.cs @@ -0,0 +1,17 @@ +/// +/// Randomizes the velocity of the bat. +/// +public void RandomizeVelocity() +{ + // Generate a random angle + float angle = (float)(Random.Shared.NextDouble() * MathHelper.TwoPi); + + // Convert the angle to a direction vector + float x = (float)Math.Cos(angle); + float y = (float)Math.Sin(angle); + Vector2 direction = new Vector2(x, y); + + // Multiply the direction vector by the movement speed to get the + // final velocity + _velocity = direction * MOVEMENT_SPEED; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/update.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/update.cs new file mode 100644 index 00000000..94a87d81 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/bat/update.cs @@ -0,0 +1,12 @@ +/// +/// Updates the bat. +/// +/// A snapshot of the timing values for the current update cycle. +public void Update(GameTime gameTime) +{ + // Update the animated sprite + _sprite.Update(gameTime); + + // Update the position of the bat based on the velocity. + Position += _velocity; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/gamecontroller.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/gamecontroller.cs new file mode 100644 index 00000000..aa9a7d10 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/gamecontroller.cs @@ -0,0 +1,79 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using MonoGameLibrary; +using MonoGameLibrary.Input; + +namespace DungeonSlime; + +/// +/// Provides a game-specific input abstraction that maps physical inputs +/// to game actions, bridging our input system with game-specific functionality. +/// +public static class GameController +{ + private static KeyboardInfo s_keyboard => Core.Input.Keyboard; + private static GamePadInfo s_gamePad => Core.Input.GamePads[(int)PlayerIndex.One]; + + /// + /// Returns true if the player has triggered the "move up" action. + /// + public static bool MoveUp() + { + return s_keyboard.WasKeyJustPressed(Keys.Up) || + s_keyboard.WasKeyJustPressed(Keys.W) || + s_gamePad.WasButtonJustPressed(Buttons.DPadUp) || + s_gamePad.WasButtonJustPressed(Buttons.LeftThumbstickUp); + } + + /// + /// Returns true if the player has triggered the "move down" action. + /// + public static bool MoveDown() + { + return s_keyboard.WasKeyJustPressed(Keys.Down) || + s_keyboard.WasKeyJustPressed(Keys.S) || + s_gamePad.WasButtonJustPressed(Buttons.DPadDown) || + s_gamePad.WasButtonJustPressed(Buttons.LeftThumbstickDown); + } + + /// + /// Returns true if the player has triggered the "move left" action. + /// + public static bool MoveLeft() + { + return s_keyboard.WasKeyJustPressed(Keys.Left) || + s_keyboard.WasKeyJustPressed(Keys.A) || + s_gamePad.WasButtonJustPressed(Buttons.DPadLeft) || + s_gamePad.WasButtonJustPressed(Buttons.LeftThumbstickLeft); + } + + /// + /// Returns true if the player has triggered the "move right" action. + /// + public static bool MoveRight() + { + return s_keyboard.WasKeyJustPressed(Keys.Right) || + s_keyboard.WasKeyJustPressed(Keys.D) || + s_gamePad.WasButtonJustPressed(Buttons.DPadRight) || + s_gamePad.WasButtonJustPressed(Buttons.LeftThumbstickRight); + } + + /// + /// Returns true if the player has triggered the "pause" action. + /// + public static bool Pause() + { + return s_keyboard.WasKeyJustPressed(Keys.Escape) || + s_gamePad.WasButtonJustPressed(Buttons.Start); + } + + /// + /// Returns true if the player has triggered the "action" button, + /// typically used for menu confirmation. + /// + public static bool Action() + { + return s_keyboard.WasKeyJustPressed(Keys.Enter) || + s_gamePad.WasButtonJustPressed(Buttons.A); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/constructor.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/constructor.cs new file mode 100644 index 00000000..937eb66d --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/constructor.cs @@ -0,0 +1,8 @@ +/// +/// Creates a new Slime using the specified animated sprite. +/// +/// The AnimatedSprite to use when drawing the slime. +public Slime(AnimatedSprite sprite) +{ + _sprite = sprite; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/definition.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/definition.cs new file mode 100644 index 00000000..3a62e9a2 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/definition.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; + +namespace DungeonSlime.GameObjects; + +public class Slime +{ + +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/draw.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/draw.cs new file mode 100644 index 00000000..6b8a122b --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/draw.cs @@ -0,0 +1,18 @@ +/// +/// Draws the slime. +/// +public void Draw() +{ + // Iterate through each segment and draw it + foreach (SlimeSegment segment in _segments) + { + // Calculate the visual position of the segment at the moment by + // lerping between its "at" and "to" position by the movement + // offset lerp amount + Vector2 pos = Vector2.Lerp(segment.At, segment.To, _movementProgress); + + // Draw the slime sprite at the calculated visual position of this + // segment + _sprite.Draw(Core.SpriteBatch, pos); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/events.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/events.cs new file mode 100644 index 00000000..7c475406 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/events.cs @@ -0,0 +1,5 @@ +/// +/// Event that is raised if it is detected that the head segment of the slime +/// has collided with a body segment. +/// +public event EventHandler BodyCollision; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/fields.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/fields.cs new file mode 100644 index 00000000..08dd4bca --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/fields.cs @@ -0,0 +1,22 @@ +// A constant value that represents the amount of time to wait between +// movement updates. +private static readonly TimeSpan s_movementTime = TimeSpan.FromMilliseconds(200); + +// The amount of time that has elapsed since the last movement update. +private TimeSpan _movementTimer; + +// Normalized value (0-1) representing progress between movement ticks for visual interpolation +private float _movementProgress; + +// The next direction to apply to the head of the slime chain during the +// next movement update. +private Vector2 _nextDirection; + +// The number of pixels to move the head segment during the movement cycle. +private float _stride; + +// Tracks the segments of the slime chain. +private List _segments; + +// The AnimatedSprite used when drawing each slime segment +private AnimatedSprite _sprite; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/getbounds.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/getbounds.cs new file mode 100644 index 00000000..3ee0d033 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/getbounds.cs @@ -0,0 +1,22 @@ +/// +/// Returns a Circle value that represents collision bounds of the slime. +/// +/// A Circle value. +public Circle GetBounds() +{ + SlimeSegment head = _segments[0]; + + // Calculate the visual position of the head at the moment of this + // method call by lerping between the "at" and "to" position by the + // movement offset lerp amount + Vector2 pos = Vector2.Lerp(head.At, head.To, _movementProgress); + + // Create the bounds using the calculated visual position of the head. + Circle bounds = new Circle( + (int)(pos.X + (_sprite.Width * 0.5f)), + (int)(pos.Y + (_sprite.Height * 0.5f)), + (int)(_sprite.Width * 0.5f) + ); + + return bounds; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/grow.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/grow.cs new file mode 100644 index 00000000..c10cf5a8 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/grow.cs @@ -0,0 +1,18 @@ +/// +/// Informs the slime to grow by one segment. +/// +public void Grow() +{ + // Capture the value of the tail segment + SlimeSegment tail = _segments[_segments.Count - 1]; + + // Create a new tail segment that is positioned a grid cell in the + // reverse direction from the tail moving to the tail. + SlimeSegment newTail = new SlimeSegment(); + newTail.At = tail.To + tail.ReverseDirection * _stride; + newTail.To = tail.At; + newTail.Direction = Vector2.Normalize(tail.At - newTail.At); + + // Add the new tail segment + _segments.Add(newTail); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/handleinput.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/handleinput.cs new file mode 100644 index 00000000..9a51f27b --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/handleinput.cs @@ -0,0 +1,29 @@ +private void HandleInput() +{ + Vector2 potentialNextDirection = _nextDirection; + + if (GameController.MoveUp()) + { + potentialNextDirection = -Vector2.UnitY; + } + else if (GameController.MoveDown()) + { + potentialNextDirection = Vector2.UnitY; + } + else if (GameController.MoveLeft()) + { + potentialNextDirection = -Vector2.UnitX; + } + else if (GameController.MoveRight()) + { + potentialNextDirection = Vector2.UnitX; + } + + // Only allow direction change if it is not reversing the current + // direction. This prevents the slime from backing into itself. + float dot = Vector2.Dot(potentialNextDirection, _segments[0].Direction); + if (dot >= 0) + { + _nextDirection = potentialNextDirection; + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/initialize.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/initialize.cs new file mode 100644 index 00000000..3321a5bc --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/initialize.cs @@ -0,0 +1,29 @@ +/// +/// Initializes the slime, can be used to reset it back to an initial state. +/// +/// The position the slime should start at. +/// The total number of pixels to move the head segment during each movement cycle. +public void Initialize(Vector2 startingPosition, float stride) +{ + // Initialize the segment collection. + _segments = new List(); + + // Set the stride + _stride = stride; + + // Create the initial head of the slime chain. + SlimeSegment head = new SlimeSegment(); + head.At = startingPosition; + head.To = startingPosition + new Vector2(_stride, 0); + head.Direction = Vector2.UnitX; + + // Add it to the segment collection. + _segments.Add(head); + + // Set the initial next direction as the same direction the head is + // moving. + _nextDirection = head.Direction; + + // Zero out the movement timer. + _movementTimer = TimeSpan.Zero; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/move.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/move.cs new file mode 100644 index 00000000..d3b6c5ec --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/move.cs @@ -0,0 +1,42 @@ +private void Move() +{ + // Capture the value of the head segment + SlimeSegment head = _segments[0]; + + // Update the direction the head is supposed to move in to the + // next direction cached. + head.Direction = _nextDirection; + + // Update the head's "at" position to be where it was moving "to" + head.At = head.To; + + // Update the head's "to" position to the next tile in the direction + // it is moving. + head.To = head.At + head.Direction * _stride; + + // Insert the new adjusted value for the head at the front of the + // segments and remove the tail segment. This effectively moves + // the entire chain forward without needing to loop through every + // segment and update its "at" and "to" positions. + _segments.Insert(0, head); + _segments.RemoveAt(_segments.Count - 1); + + // Iterate through all of the segments except the head and check + // if they are at the same position as the head. If they are, then + // the head is colliding with a body segment and a body collision + // has occurred. + for (int i = 1; i < _segments.Count; i++) + { + SlimeSegment segment = _segments[i]; + + if (head.At == segment.At) + { + if(BodyCollision != null) + { + BodyCollision.Invoke(this, EventArgs.Empty); + } + + return; + } + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/update.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/update.cs new file mode 100644 index 00000000..4d9bec68 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slime/update.cs @@ -0,0 +1,26 @@ +/// +/// Updates the slime. +/// +/// A snapshot of the timing values for the current update cycle. +public void Update(GameTime gameTime) +{ + // Update the animated sprite. + _sprite.Update(gameTime); + + // Handle any player input + HandleInput(); + + // Increment the movement timer by the frame elapsed time. + _movementTimer += gameTime.ElapsedGameTime; + + // If the movement timer has accumulated enough time to be greater than + // the movement time threshold, then perform a full movement. + if (_movementTimer >= s_movementTime) + { + _movementTimer -= s_movementTime; + Move(); + } + + // Update the movement lerp offset amount + _movementProgress = (float)(_movementTimer.TotalSeconds / s_movementTime.TotalSeconds); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slimesegment.cs b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slimesegment.cs new file mode 100644 index 00000000..2c9531d9 --- /dev/null +++ b/articles/tutorials/building_2d_games/22_snake_game_mechanics/snippets/slimesegment.cs @@ -0,0 +1,26 @@ +using Microsoft.Xna.Framework; + +namespace DungeonSlime.GameObjects; + +public struct SlimeSegment +{ + /// + /// The position this slime segment is at before the movement cycle occurs. + /// + public Vector2 At; + + /// + /// The position this slime segment should move to during the next movement cycle. + /// + public Vector2 To; + + /// + /// The direction this slime segment is moving. + /// + public Vector2 Direction; + + /// + /// The opposite direction this slime segment is moving. + /// + public Vector2 ReverseDirection => new Vector2(-Direction.X, -Direction.Y); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/index.md b/articles/tutorials/building_2d_games/23_completing_the_game/index.md new file mode 100644 index 00000000..697ae71f --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/index.md @@ -0,0 +1,440 @@ +--- +title: "Chapter 23: Completing the Game" +description: "Finalize game mechanics by updating our current demo into a snake-like inspired game." +--- + +In [Chapter 22](../22_snake_game_mechanics/index.md) we implemented the core mechanics of a snake-like game by creating the [`Slime`](../22_snake_game_mechanics/index.md#the-slime-class), [`Bat`](../22_snake_game_mechanics/index.md#the-bat-class) and [`GameController`](../22_snake_game_mechanics/index.md#the-gamecontroller-class) classes. While these classes handle the foundational gameplay, a complete game needs additional elements to provide player feedback, manage game states, and create a polished experience. + +In this chapter, you will: + +- Create a dedicated UI class to manage the UI for the game scene. +- Implement pause and game over screens with appropriate controls. +- Refactor the `GameScene` class to coordinate all game elements. +- Add game state management to handle playing, paused, and game over conditions +- Implement input buffering to improve control responsiveness +- Connect all elements to create a complete, playable game. + +## The GameSceneUI Class + +Currently, the `GameScene` class contains the methods for initializing and creating the pause menu.  However, now that we have a defined condition for game over, we need to create a game-over menu as well.  To do this, we will take the opportunity to refactor the current code and pull the UI-specific code into its own class. + +In the `UI` directory of the *DungeonSlime* project, create a new file named `GameSceneUI.cs` and add the following initial code: + +[!code-csharp[](./snippets/gamesceneui/definition.cs)] + +This code establishes the foundation for our `GameSceneUI` class, which inherits from Gum's `ContainerRuntime` class. This inheritance means our UI class is itself a UI container that can hold and manage other UI elements. We have included all necessary using statements for MonoGame, Gum UI components, and our library references. + +You will build out this class by adding each section in sequence. Follow the order below to create the complete UI management system for our game scene. + +> [!NOTE] +> You may see compiler errors as you add these sections one by one. This is expected because some parts of the code will reference fields, properties, or methods that we haven't added yet. Once all sections are in place, these errors will resolve. + +### GameSceneUI Fields + +Add the following fields to the `GameSceneUI` class: + +[!code-csharp[](./snippets/gamesceneui/fields.cs)] + +Here is a break down what each of these fields is responsible for: + +- `s_scoreFormat`: A string format template used to display the player's score with leading zeros. +- `_uiSoundEffect`: Stores the sound effect played for UI interactions like button clicks and focus changes. +- `_pausePanel`: The panel containing the UI elements shown when the game is paused. +- `_resumeButton`: A reference to the resume button, allowing us to set focus on it when the pause panel is shown. +- `_gameOverPanel`: The panel containing the UI elements shown when a game over occurs. +- `_retryButton`: A reference to the retry button, allowing us to set focus to it when the game over panel is shown. +- `_scoreText`: The text display showing the player's current score. + +### GameSceneUI Events + +After the fields, add the following events to the `GameSceneUI` class: + +[!code-csharp[](./snippets/gamesceneui/events.cs)] + +These events allow the `GameSceneUI` class to notify the `GameScene` when important UI actions occur: + +- `ResumeButtonClick`: Triggered when the player clicks the Resume button on the pause panel. +- `QuitButtonClick`: Triggered when the player clicks the Quit button on either panel. +- `RetryButtonClick`: Triggered when the player clicks the Retry button on the game over panel. + +### GameSceneUI Constructor + +Add the following constructor to the `GameSceneUI` class after the events: + +[!code-csharp[](./snippets/gamesceneui/constructor.cs)] + +This constructor initializes all UI components: + +1. Set the container to fill the entire screen. +2. Adds itself to Gum's root element. +3. Loads necessary assets (sound effect and texture atlas). +4. Creates and adds child elements in the correct order. + +### GameSceneUI UI Creation Methods + +To keep the code more organized, we will create separate functions to build the individual UI elements that will be managed by the `GameSceneUI` class. + +#### Creating the Score Text + +To display the player's score, we will begin by adding a method to create a `TextRuntime` element. + +Add the following method to the `GameSceneUI` after the constructor: + +[!code-csharp[](./snippets/gamesceneui/createscoretext.cs)] + +#### Creating the Pause Panel + +Next, we will add a method to create a `Panel` element that is shown when the game is paused, including the "Resume" and "Quit" buttons. + +Add the following method to the `GameSceneUI` class after the `CreateScoreText` method: + +[!code-csharp[](./snippets/gamesceneui/createpausepanel.cs)] + +#### Creating the Game Over Panel + +Finally, we will add a method to create a `Panel` element that is shown when a game over occurs, including the "Retry" and "Quit" buttons. + +Add the following method to the `GameSceneUI` class after the `CreatePausePanel` method: + +[!code-csharp[](./snippets/gamesceneui/creategameoverpanel.cs)] + +Both the pause panel and the game over panel use event handlers for their buttons. We will add those next. + +### GameSceneUI Event Handlers + +After the `CreateGameOverPanel` method, add the following method to the `GameSceneUI` class: + +[!code-csharp[](./snippets/gamesceneui/eventhandlers.cs)] + +These event handlers provide audio feedback and appropriate UI updates when buttons are clicked or UI elements receive focus. + +### GameSceneUI Public Methods + +Finally, add the following public methods to the `GameSceneUI` class after the `OnElementGotFocus` method: + +[!code-csharp[](./snippets/gamesceneui/publicmethods.cs)] + +These public methods provide the interface for the `GameScene` to implement: + +- Update the score display. +- Show or hide the pause menu. +- Show or hide the game over menu. +- Update and draw the UI components. + +With the `GameSceneUI` class complete, we now have a fully encapsulated UI system that can handle displaying game information (score), providing feedback for game states (pause, game over), and processing user interactions (button clicks). This separation of UI logic from game logic will make our codebase much easier to maintain and extend. + +Now that we have all our specialized components ready, we can refactor the `GameScene` class to coordinate between them and manage the overall game flow. + +## Refactoring The GameScene Class + +Now that we have created the encapsulated [`Slime`](../22_snake_game_mechanics/index.md#the-slime-class), [`Bat`](../22_snake_game_mechanics/index.md#the-bat-class), and [`GameSceneUI`](#the-gamesceneui-class) classes, we can refactor the `GameScene` class to leverage these new components.  This will make our code more maintainable and allow us to focus on the game logic within the scene itself.   + +We will rebuild/replace the existing `GameScene` class to coordinate the interactions between the components. + +In the `Scenes` directory of the *DungeonSlime* project (your main game project), open the `GameScene.cs` file and replace **ALL** of the code with the following replacement code (starting fresh): + +[!code-csharp[](./snippets/gamescene/definition.cs)] + +This code provides the foundation for our refactored `GameScene` class. We have included all the necessary using statements to reference our new game object classes and UI components. The class will now focus on managing the game state and coordinating between our specialized component classes rather than implementing all the functionality directly. + +The `GameScene` class now contains the following key fields: + +- `GameState`: An enum that defines the different states that the game can be in (playing, paused, or game over). +- `_slime`: A reference to the slime (snake-like player character) instance. +- `_bat`: A reference to the bat (food) instance. +- `_tilemap`: The tilemap that defines the level layout. +- `_roomBounds`: A rectangle defining the playable area within the walls. +- `_collectSoundEffect`: The sound effect played when the slime eats a bat. +- `_score`: Tracks the player's current score. +- `_ui`: A reference to the game scene UI component. +- `_state`: The current state of the game represented by the `GameState` enum. + +Next we will add the various methods needed to complete the `GameScene` class and finalize the game logic. + +Add each section in the sequence presented below. This will build up the scene's functionality step by step. + +> [!NOTE] +> As with previous classes, you might encounter compiler errors until all sections are in place. These errors will be resolved once all components of the class have been added. + +### GameScene Initialize Method + +To set up the scene, add the following `Initialize` method after the fields in te `GameScene` class: + +[!code-csharp[](./snippets/gamescene/initialize.cs)] + +This method sets up the initial state of the game scene: + +1. Disables the "exit on escape" behavior so we can use the escape key for pausing. +2. Calculate the playable area within the tilemap walls. +3. Subscribes to the slime's body collision event to detect when the player collides with itself triggering a game over state. +4. Initialize the UI components. +5. Set up a new game. + +### GameScene InitializeUI Method + +The `Initialize` method we just added calls a method to initialize the user interface for the scene, we can add that method now. + +Add the following method after the `Initialize` method in the `GameScene` class: + +[!code-csharp[](./snippets/gamescene/initializeui.cs)] + +This method creates the UI components and subscribes to its events to respond to button clicks. + +### GameScene UI Event Handlers + +In the `InitializeUI` method we just added, we subscribe to the events from the `GameSceneUI` class that are triggered when buttons are clicked. Now we need to add those methods that would be called when the events are triggered. + +Add the following methods to the `GameScene` class after the `InitializeUI` method: + +[!code-csharp[](./snippets/gamescene/eventhandlers.cs)] + +These methods respond to the UI events: + +- `OnResumeButtonClicked`: Resumes the game from a paused state. +- `OnRetryButtonClicked`: Restarts the game after a game over. +- `OnQuitButtonClicked`: Quits the game by returning to the title scene. + +### GameScene InitializeNewGame Method + +In the `Initialize` method we added above, it also makes a call to an `InitializeNewGame` method which we will add next. + +Add the following method to the `GameScene` class after the `OnQuitButtonClicked` method: + +[!code-csharp[](./snippets/gamescene/initializenewgame.cs)] + +This method will: + +1. Position the slime in the center of the map. +2. Initialize the slime with its starting position and movement stride. +3. Randomize the bat's velocity and position it away from the slime. +4. Reset the player's score. +5. Set the game state to "Playing". + +### GameScene LoadContent Method + +Next, we need to add the method to load game assets for the scene. + +Add the following method to the `GameScene` class after the `InitializeNewGame` method: + +[!code-csharp[](./snippets/gamescene/loadcontent.cs)] + +This method loads all necessary assets for the game scene: + +1. The texture atlas containing the sprite graphics +2. The tilemap that defines the level layout. +3. The animated sprites for the slime and bat. +4. Sound effects for the bat bouncing and collecting. + +### GameScene Update Method + +Next, to update the scene, add the following method to the `GameScene` class after the `LoadContent` method: + +[!code-csharp[](./snippets/gamescene/update.cs)] + +This method updates the scene in each frame to: + +1. Always update the UI, regardless of game state. +2. Return early if the game is over. +3. Check for pause input and toggle the pause state if needed. +4. Return early if the game is paused. +5. Update the slime and bat. +6. Check for collisions between the game objects. + +### GameScene CollisionChecks Method + +In the `Update` method we just added, it makes a call to a `CollisionChecks` method to handle the collision detection and response so we will add tha now. + +Add the following method to the `GameScene` class after the `Update` method: + +[!code-csharp[](./snippets/gamescene/collisionchecks.cs)] + +This method checks for three types of collisions: + +1. Slime-Bat collision: The slime "eats" the bat, gains points, grows, and the bat respawns. +2. Slime-Wall collision: Triggers a game over if the slime hits a wall. +3. Bat-Wall collision: Causes the bat to bounce off the walls. + +### GameScene PositionBatAwayFromSlime Method + +The `CollisionCheck` method makes a call to `PositionBatAwayFromSlime`, previously, when we needed to set the position of the bat when it respawns, we simply chose a random tile within the tilemap to move it to. However, by choosing a completely random location it could be on top fo the head segment of the slime, forcing an instant collision, or it could spawn very close to the head segment, which is not challenging for the player. To ensure the bat appears in a random, but strategic location, we can instead set it to position away from the slime on the opposite side of the room. + +Add the following method to the `GameScene` class after the `CollisionCheck` method: + +[!code-csharp[](./snippets/gamescene/positionbatawayfromslime.cs)] + +This method positions the bat after it has been eaten: + +1. Determines which wall (top, bottom, left, or right) is furthest from the slime. +2. Places the bat near that wall, making it more challenging for the player to reach. + +### GameScene Event Handler and Game State Methods + +Next, we will add some of the missing methods being called from above that handle game events and state changes. + +Add the following methods to the `GameScene` class after the `PositionBatAwayFromSlime` method: + +[!code-csharp[](./snippets/gamescene/statechanges.cs)] + +These methods handle specific game events: + +- `OnSlimeBodyCollision`: Called when the slime collides with itself, triggering a game over. +- `TogglePause`: Switches between paused and playing states. +- `GameOver`: Called when a game over condition is met, showing the game over UI. + +### GameScene Draw Method + +Finally, we need a method to draw the scene. + +Add the following method to the `GameScene` class after the `GameOver` method. + +[!code-csharp[](./snippets/gamescene/draw.cs)] + +This method handles drawing the scene by: + +1. Clearing the screen. +2. Drawing the tilemap as the background. +3. Drawing the slime and bat sprites. +4. Drawing the UI elements on top. + +By refactoring our game into these encapsulated components, we have created a more maintainable codebase with a clear separation of concerns: + +- The `Slime` class handles snake-like movement and growth. +- The `Bat` class manages its movement and bouncing. +- The `GameSceneUI` class manages all UI components. +- The `GameScene` class coordinates between these components and manages the game state. + +This architecture makes it easier to add new features or fix bugs, as changes to one component are less likely to affect others. + +## Adding Input Buffering to the Slime Class + +The game at this point is now playable. If you test it out though, you may notice a small issue with inputs. As we discussed in [Chapter 10](../10_handling_input/index.md#input-buffering), in games where movement updates happen at fixed intervals, inputs can sometimes feel unresponsive, especially when trying to make multiple inputs in succession. + +For instance, if a player wants to navigate a tight corner by pressing up and then immediately left, pressing these keys in rapid succession often results in only the second input being registered. When this happens, the slime will only continue left without first moving upward, missing the intended two-part movement completely. This occurs because the second input overwrites the first one before the game has a chance to process it, leading to frustrating gameplay. + +Implementing the input buffering technique we introduced in [Chapter 10](../10_handling_input/index.md#implementing-a-simple-input-buffer) aims to solve this problem in our `Slime` class. + +### Implementing Input Buffering in the Slime Class + +For the `Slime` class, we will implement input buffering based on the example given using a `Queue` in [Chapter 10](../10_handling_input/index.md#implementing-a-simple-input-buffer). In the `GameObject` folder of the *DungeonSlime* project (your main game project), open the `Slime.cs` file so we can make the changes. + +First, update the using statements at the top of the `Slime` class to add the `System.Linq` using statement: + +[!code-csharp[](./snippets/slime/usings.cs?highlight=3)] + +Next, add the following fields to the `Slime` class after the `_sprite` field: + +[!code-csharp[](./snippets/slime/fields.cs)] + +The queue will store the directional vectors (up, down, left, right) that we will apply to the slime's movement in the order they were received. Next, we need to initialize the queue. + +In the `Slime` class, locate the `Initialize` method and and update it to the following: + +[!code-csharp[](./snippets/slime/initialize.cs?highlight=30-31)] + +Next we need to update the input handling method to store the inputs in the queue instead of immediately overwriting the `_nextDirection` field. + +In the `Slime` class, locate the `HandleInput` method and update it to the following: + +[!code-csharp[](./snippets/slime/handleinput.cs?highlight=3,22-38)] + +1. The `potentialNewDirection` is now given the initial value of [**Vector2.Zero**](xref:Microsoft.Xna.Framework.Vector2.Zero). +2. A check is made to see if the player has pressed a direction key and if the input buffer is not already at maximum capacity. +3. If a new direction key is pressed and the buffer has space: + 1. The validation is made using [**Vector2.Dot**](xref:Microsoft.Xna.Framework.Vector2.Dot(Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Vector2)) just like before to ensure it is a valid direction + 2. If it is a valid direction, then it is added to the queue. + +Finally, we need to modify how we apply the movement direction during the movement update cycle. + +In the `Slime` class, locate the `Move` method and update it to the following: + +[!code-csharp[](./snippets/slime/move.cs?highlight=3-7)] + +The key change here is that we now dequeue a direction from the input buffer rather than directly using the `_nextDirection` value. This ensures we process inputs in the order they were received, preserving the player's intent. + +With these changes in place, our game now supports input buffering. This small enhancement improves how the game feels to play, particularly when making rapid directional changes. + +Players will notice: + +- When navigating a corner, they can quickly press up followed by left (or any other valid combination), and both inputs will be respected +- The game feels more responsive since it remembers inputs between movement update cycles +- Complex maneuvers are easier to execute since timing is more forgiving + +The difference might seem subtle, but it significantly reduces frustration during gameplay. + +## Putting It All Together + +With all of these components now in place, our Dungeon Slime game has transformed from a simple demo built on learning MonoGame concepts into a complete snake-like game experience.  The player controls the slime that moves through the dungeon, consuming bats to grow longer.  If the slime collides with the wall or its own body, the game ends. + +Now we can see how it all looks and plays: + +| ![Figure 23-1: Gameplay demonstration of the completed Dungeon Slime game showing the snake-like slime growing as it eats bats and a game over when colliding with the wall ](./videos/gameplay.webm) | +| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 23-1: Gameplay demonstration of the completed Dungeon Slime game showing the snake-like slime growing as it eats bats and a game over when colliding with the wall** | + +1. The game starts with a single slime segment in the center of the room. +2. The player controls the direction of the slime by using the keyboard (arrow keys or WASD) or by using a game pad (DPad or left thumbstick). +3. The slime moves at regular intervals, creating a grid-based movement pattern. +4. When the slime eats a bat, it grows longer by adding a new segment to its tail. +5. The bat respawns at a strategic location after being eaten. +6. The player's score increases with each bat consumed. +7. If the slime collides with a wall or its own body, the game over panel appears. +8. On the game over panel, the player can choose to retry or return to the title scene. + +With these mechanics implemented, Dungeon Slime is now a complete game with clear objectives, escalating difficulty, and a game feedback loop. + +## Conclusion + +In this chapter, we have transformed our technical demo into a complete game by integrating UI systems with game mechanics. We have accomplished several important goals: + +- Created a dedicated [`GameSceneUI`](#the-gamesceneui-class) class to manage the game's user interface. +- Implemented pause and game over screens that provide clear feedback to the player. +- Refactored the `GameScene` class to coordinate all game components. +- Added game state management to handle different gameplay conditions. +- Enhanced player control through input buffering for more responsive gameplay +- Connected all of the elements to create a complete playable game. + +The refactoring process we undertook demonstrates an important game development principle: separating concerns into specialized components makes code more maintainable and easier to extend. The `Slime` class manages snake-like behavior, the `Bat` class handles movement and collision response, and the `GameSceneUI` class encapsulates all UI-related functionality. + +## Test Your Knowledge + +1. How does the game handle different states (playing, paused, game over), and why is this state management important? + + :::question-answer + The game uses an enum (`GameState`) to track its current state and implements different behavior based on that state: + + - During the `Playing` state, the game updates all objects and checks for collisions + - During the `Paused` state, the game shows the pause menu and stops updating game objects + - During the `GameOver` state, the game shows the game over menu and prevents further gameplay + + This state management is important because it: + + - Prevents inappropriate updates during non-gameplay states + - Creates a clear flow between different game conditions + - Simplifies conditional logic by using explicit states rather than multiple boolean flags + - Makes the game's behavior more predictable and easier to debug + + ::: + +2. Why is it important to position the bat away from the slime after it has been eaten rather than at a completely random location? + + :::question-answer + Positioning the bat away from the slime after it has been eaten rather than at a completely random location is important because: + + - It prevents unfair situations where the bat might spawn right on top of the slime causing an immediate collision + - It creates a more strategic gameplay experience by forcing the player to navigate toward the bat + - It ensures the player faces an appropriate level of challenge that increases as the slime grows longer + - It prevents potential frustration from random spawns that might be either too easy or too difficult to reach + - It creates a more balanced and predictable game experience while still maintaining variety + ::: + +3. What problem does input buffering solve and how does our implementation address it? + + :::question-answer + Input buffering solves the timing disconnect between when players press buttons and when the game can actually process those inputs in games with fixed movement cycles. Without buffering, inputs that occur between movement cycles are lost, especially when players make rapid sequential inputs like navigating corners. + + Our implementation addresses this by: + + - Using a queue data structure to store up to two directional inputs + - Processing inputs in First-In-First-Out order to preserve the player's intended sequence + - Validating each input against the previous one to prevent impossible movements + ::: diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/collisionchecks.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/collisionchecks.cs new file mode 100644 index 00000000..7fc83597 --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/collisionchecks.cs @@ -0,0 +1,63 @@ +private void CollisionChecks() +{ + // Capture the current bounds of the slime and bat + Circle slimeBounds = _slime.GetBounds(); + Circle batBounds = _bat.GetBounds(); + + // FIrst perform a collision check to see if the slime is colliding with + // the bat, which means the slime eats the bat. + if (slimeBounds.Intersects(batBounds)) + { + // Move the bat to a new position away from the slime. + PositionBatAwayFromSlime(); + + // Randomize the velocity of the bat. + _bat.RandomizeVelocity(); + + // Tell the slime to grow. + _slime.Grow(); + + // Increment the score. + _score += 100; + + // Update the score display on the UI. + _ui.UpdateScoreText(_score); + + // Play the collect sound effect + Core.Audio.PlaySoundEffect(_collectSoundEffect); + } + + // Next check if the slime is colliding with the wall by validating if + // it is within the bounds of the room. If it is outside the room + // bounds, then it collided with a wall which triggers a game over. + if (slimeBounds.Top < _roomBounds.Top || + slimeBounds.Bottom > _roomBounds.Bottom || + slimeBounds.Left < _roomBounds.Left || + slimeBounds.Right > _roomBounds.Right) + { + GameOver(); + return; + } + + // Finally, check if the bat is colliding with a wall by validating if + // it is within the bounds of the room. If it is outside the room + // bounds, then it collided with a wall, and the bat should bounce + // off of that wall. + if (batBounds.Top < _roomBounds.Top) + { + _bat.Bounce(Vector2.UnitY); + } + else if (batBounds.Bottom > _roomBounds.Bottom) + { + _bat.Bounce(-Vector2.UnitY); + } + + if (batBounds.Left < _roomBounds.Left) + { + _bat.Bounce(Vector2.UnitX); + } + else if (batBounds.Right > _roomBounds.Right) + { + _bat.Bounce(-Vector2.UnitX); + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/definition.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/definition.cs new file mode 100644 index 00000000..a2ee52a1 --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/definition.cs @@ -0,0 +1,44 @@ +using System; +using DungeonSlime.GameObjects; +using DungeonSlime.UI; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Graphics; +using MonoGameGum; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; +using MonoGameLibrary.Scenes; + +namespace DungeonSlime.Scenes; + +public class GameScene : Scene +{ + private enum GameState + { + Playing, + Paused, + GameOver + } + + // Reference to the slime. + private Slime _slime; + + // Reference to the bat. + private Bat _bat; + + // Defines the tilemap to draw. + private Tilemap _tilemap; + + // Defines the bounds of the room that the slime and bat are contained within. + private Rectangle _roomBounds; + + // The sound effect to play when the slime eats a bat. + private SoundEffect _collectSoundEffect; + + // Tracks the players score. + private int _score; + + private GameSceneUI _ui; + + private GameState _state; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/draw.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/draw.cs new file mode 100644 index 00000000..d27a31dd --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/draw.cs @@ -0,0 +1,23 @@ +public override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + Core.GraphicsDevice.Clear(Color.CornflowerBlue); + + // Begin the sprite batch to prepare for rendering. + Core.SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + + // Draw the tilemap + _tilemap.Draw(Core.SpriteBatch); + + // Draw the slime. + _slime.Draw(); + + // Draw the bat. + _bat.Draw(); + + // Always end the sprite batch when finished. + Core.SpriteBatch.End(); + + // Draw the UI + _ui.Draw(); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/eventhandlers.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/eventhandlers.cs new file mode 100644 index 00000000..806f7391 --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/eventhandlers.cs @@ -0,0 +1,17 @@ +private void OnResumeButtonClicked(object sender, EventArgs args) +{ + // Change the game state back to playing + _state = GameState.Playing; +} + +private void OnRetryButtonClicked(object sender, EventArgs args) +{ + // Player has chosen to retry, so initialize a new game + InitializeNewGame(); +} + +private void OnQuitButtonClicked(object sender, EventArgs args) +{ + // Player has chosen to quit, so return back to the title scene + Core.ChangeScene(new TitleScene()); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/initialize.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/initialize.cs new file mode 100644 index 00000000..ed935d06 --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/initialize.cs @@ -0,0 +1,30 @@ +public override void Initialize() +{ + // LoadContent is called during base.Initialize(). + base.Initialize(); + + // During the game scene, we want to disable exit on escape. Instead, + // the escape key will be used to return back to the title screen + Core.ExitOnEscape = false; + + // Create the room bounds by getting the bounds of the screen then + // using the Inflate method to "Deflate" the bounds by the width and + // height of a tile so that the bounds only covers the inside room of + // the dungeon tilemap. + _roomBounds = Core.GraphicsDevice.PresentationParameters.Bounds; + _roomBounds.Inflate(-_tilemap.TileWidth, -_tilemap.TileHeight); + + // Subscribe to the slime's BodyCollision event so that a game over + // can be triggered when this event is raised. + _slime.BodyCollision += OnSlimeBodyCollision; + + // Create any UI elements from the root element created in previous + // scenes + GumService.Default.Root.Children.Clear(); + + // Initialize the user interface for the game scene. + InitializeUI(); + + // Initialize a new game to be played. + InitializeNewGame(); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/initializenewgame.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/initializenewgame.cs new file mode 100644 index 00000000..cd6e1ade --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/initializenewgame.cs @@ -0,0 +1,21 @@ +private void InitializeNewGame() +{ + // Calculate the position for the slime, which will be at the center + // tile of the tile map. + Vector2 slimePos = new Vector2(); + slimePos.X = (_tilemap.Columns / 2) * _tilemap.TileWidth; + slimePos.Y = (_tilemap.Rows / 2) * _tilemap.TileHeight; + + // Initialize the slime + _slime.Initialize(slimePos, _tilemap.TileWidth); + + // Initialize the bat + _bat.RandomizeVelocity(); + PositionBatAwayFromSlime(); + + // Reset the score + _score = 0; + + // Set the game state to playing + _state = GameState.Playing; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/initializeui.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/initializeui.cs new file mode 100644 index 00000000..3ba4f071 --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/initializeui.cs @@ -0,0 +1,14 @@ +private void InitializeUI() +{ + // Clear out any previous UI element incase we came here + // from a different scene. + GumService.Default.Root.Children.Clear(); + + // Create the game scene ui instance. + _ui = new GameSceneUI(); + + // Subscribe to the events from the game scene ui. + _ui.ResumeButtonClick += OnResumeButtonClicked; + _ui.RetryButtonClick += OnRetryButtonClicked; + _ui.QuitButtonClick += OnQuitButtonClicked; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/loadcontent.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/loadcontent.cs new file mode 100644 index 00000000..58b2bb92 --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/loadcontent.cs @@ -0,0 +1,29 @@ +public override void LoadContent() +{ + // Create the texture atlas from the XML configuration file + TextureAtlas atlas = TextureAtlas.FromFile(Core.Content, "images/atlas-definition.xml"); + + // Create the tilemap from the XML configuration file. + _tilemap = Tilemap.FromFile(Content, "images/tilemap-definition.xml"); + _tilemap.Scale = new Vector2(4.0f, 4.0f); + + // Create the animated sprite for the slime from the atlas. + AnimatedSprite slimeAnimation = atlas.CreateAnimatedSprite("slime-animation"); + slimeAnimation.Scale = new Vector2(4.0f, 4.0f); + + // Create the slime + _slime = new Slime(slimeAnimation); + + // Create the animated sprite for the bat from the atlas. + AnimatedSprite batAnimation = atlas.CreateAnimatedSprite("bat-animation"); + batAnimation.Scale = new Vector2(4.0f, 4.0f); + + // Load the bounce sound effect for the bat + SoundEffect bounceSoundEffect = Content.Load("audio/bounce"); + + // Create the bat + _bat = new Bat(batAnimation, bounceSoundEffect); + + // Load the collect sound effect + _collectSoundEffect = Content.Load("audio/collect"); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/positionbatawayfromslime.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/positionbatawayfromslime.cs new file mode 100644 index 00000000..effc8599 --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/positionbatawayfromslime.cs @@ -0,0 +1,76 @@ +private void PositionBatAwayFromSlime() +{ + // Calculate the position that is in the center of the bounds + // of the room. + float roomCenterX = _roomBounds.X + _roomBounds.Width * 0.5f; + float roomCenterY = _roomBounds.Y + _roomBounds.Height * 0.5f; + Vector2 roomCenter = new Vector2(roomCenterX, roomCenterY); + + // Get the bounds of the slime and calculate the center position + Circle slimeBounds = _slime.GetBounds(); + Vector2 slimeCenter = new Vector2(slimeBounds.X, slimeBounds.Y); + + // Calculate the distance vector from the center of the room to the + // center of the slime. + Vector2 centerToSlime = slimeCenter - roomCenter; + + // Get the bounds of the bat + Circle batBounds =_bat.GetBounds(); + + // Calculate the amount of padding we will add to the new position of + // the bat to ensure it is not sticking to walls + int padding = batBounds.Radius * 2; + + // Calculate the new position of the bat by finding which component of + // the center to slime vector (X or Y) is larger and in which direction. + Vector2 newBatPosition = Vector2.Zero; + if (Math.Abs(centerToSlime.X) > Math.Abs(centerToSlime.Y)) + { + // The slime is closer to either the left or right wall, so the Y + // position will be a random position between the top and bottom + // walls. + newBatPosition.Y = Random.Shared.Next( + _roomBounds.Top + padding, + _roomBounds.Bottom - padding + ); + + if (centerToSlime.X > 0) + { + // The slime is closer to the right side wall, so place the + // bat on the left side wall + newBatPosition.X = _roomBounds.Left + padding; + } + else + { + // The slime is closer ot the left side wall, so place the + // bat on the right side wall. + newBatPosition.X = _roomBounds.Right - padding * 2; + } + } + else + { + // The slime is closer to either the top or bottom wall, so the X + // position will be a random position between the left and right + // walls. + newBatPosition.X = Random.Shared.Next( + _roomBounds.Left + padding, + _roomBounds.Right - padding + ); + + if (centerToSlime.Y > 0) + { + // The slime is closer to the top wall, so place the bat on the + // bottom wall + newBatPosition.Y = _roomBounds.Top + padding; + } + else + { + // The slime is closer to the bottom wall, so place the bat on + // the top wall. + newBatPosition.Y = _roomBounds.Bottom - padding * 2; + } + } + + // Assign the new bat position + _bat.Position = newBatPosition; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/statechanges.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/statechanges.cs new file mode 100644 index 00000000..1d8f7d8a --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/statechanges.cs @@ -0,0 +1,33 @@ +private void OnSlimeBodyCollision(object sender, EventArgs args) +{ + GameOver(); +} + +private void TogglePause() +{ + if (_state == GameState.Paused) + { + // We're now unpausing the game, so hide the pause panel + _ui.HidePausePanel(); + + // And set the state back to playing + _state = GameState.Playing; + } + else + { + // We're now pausing the game, so show the pause panel + _ui.ShowPausePanel(); + + // And set the state to paused + _state = GameState.Paused; + } +} + +private void GameOver() +{ + // Show the game over panel + _ui.ShowGameOverPanel(); + + // Set the game state to game over + _state = GameState.GameOver; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/update.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/update.cs new file mode 100644 index 00000000..5db7f901 --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamescene/update.cs @@ -0,0 +1,33 @@ +public override void Update(GameTime gameTime) +{ + // Ensure the UI is always updated + _ui.Update(gameTime); + + // If the game is in a game over state, immediately return back + // here + if (_state == GameState.GameOver) + { + return; + } + + // If the pause button is pressed, toggle the pause state + if (GameController.Pause()) + { + TogglePause(); + } + + // At this point, if the game is paused, just return back early + if (_state == GameState.Paused) + { + return; + } + + // Update the slime; + _slime.Update(gameTime); + + // Update the bat; + _bat.Update(gameTime); + + // Perform collision checks + CollisionChecks(); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/constructor.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/constructor.cs new file mode 100644 index 00000000..554c2bfd --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/constructor.cs @@ -0,0 +1,33 @@ +public GameSceneUI() +{ + // The game scene UI inherits from ContainerRuntime, so we set its + // doc to fill so it fills the entire screen. + Dock(Gum.Wireframe.Dock.Fill); + + // Add it to the root element. + this.AddToRoot(); + + // Get a reference to the content manager that was registered with the + // GumService when it was original initialized. + ContentManager content = GumService.Default.ContentLoader.XnaContentManager; + + // Use that content manager to load the sound effect and atlas for the + // user interface elements + _uiSoundEffect = content.Load("audio/ui"); + TextureAtlas atlas = TextureAtlas.FromFile(content, "images/atlas-definition.xml"); + + // Create the text that will display the players score and add it as + // a child to this container. + _scoreText = CreateScoreText(); + AddChild(_scoreText); + + // Create the Pause panel that is displayed when the game is paused and + // add it as a child to this container + _pausePanel = CreatePausePanel(atlas); + AddChild(_pausePanel.Visual); + + // Create the Game Over panel that is displayed when a game over occurs + // and add it as a child to this container + _gameOverPanel = CreateGameOverPanel(atlas); + AddChild(_gameOverPanel.Visual); +} diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/creategameoverpanel.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/creategameoverpanel.cs new file mode 100644 index 00000000..310d7e10 --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/creategameoverpanel.cs @@ -0,0 +1,56 @@ +private Panel CreateGameOverPanel(TextureAtlas atlas) +{ + Panel panel = new Panel(); + panel.Anchor(Gum.Wireframe.Anchor.Center); + panel.Visual.WidthUnits = DimensionUnitType.Absolute; + panel.Visual.HeightUnits = DimensionUnitType.Absolute; + panel.Visual.Width = 264.0f; + panel.Visual.Height = 70.0f; + panel.IsVisible = false; + + TextureRegion backgroundRegion = atlas.GetRegion("panel-background"); + + NineSliceRuntime background = new NineSliceRuntime(); + background.Dock(Gum.Wireframe.Dock.Fill); + background.Texture = backgroundRegion.Texture; + background.TextureAddress = TextureAddress.Custom; + background.TextureHeight = backgroundRegion.Height; + background.TextureWidth = backgroundRegion.Width; + background.TextureTop = backgroundRegion.SourceRectangle.Top; + background.TextureLeft = backgroundRegion.SourceRectangle.Left; + panel.AddChild(background); + + TextRuntime text = new TextRuntime(); + text.Text = "GAME OVER"; + text.WidthUnits = DimensionUnitType.RelativeToChildren; + text.UseCustomFont = true; + text.CustomFontFile = "fonts/04b_30.fnt"; + text.FontScale = 0.5f; + text.X = 10.0f; + text.Y = 10.0f; + panel.AddChild(text); + + _retryButton = new AnimatedButton(atlas); + _retryButton.Text = "RETRY"; + _retryButton.Anchor(Gum.Wireframe.Anchor.BottomLeft); + _retryButton.Visual.X = 9.0f; + _retryButton.Visual.Y = -9.0f; + + _retryButton.Click += OnRetryButtonClicked; + _retryButton.GotFocus += OnElementGotFocus; + + panel.AddChild(_retryButton); + + AnimatedButton quitButton = new AnimatedButton(atlas); + quitButton.Text = "QUIT"; + quitButton.Anchor(Gum.Wireframe.Anchor.BottomRight); + quitButton.Visual.X = -9.0f; + quitButton.Visual.Y = -9.0f; + + quitButton.Click += OnQuitButtonClicked; + quitButton.GotFocus += OnElementGotFocus; + + panel.AddChild(quitButton); + + return panel; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/createpausepanel.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/createpausepanel.cs new file mode 100644 index 00000000..8123d1a3 --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/createpausepanel.cs @@ -0,0 +1,55 @@ +private Panel CreatePausePanel(TextureAtlas atlas) +{ + Panel panel = new Panel(); + panel.Anchor(Gum.Wireframe.Anchor.Center); + panel.Visual.WidthUnits = DimensionUnitType.Absolute; + panel.Visual.HeightUnits = DimensionUnitType.Absolute; + panel.Visual.Width = 264.0f; + panel.Visual.Height = 70.0f; + panel.IsVisible = false; + + TextureRegion backgroundRegion = atlas.GetRegion("panel-background"); + + NineSliceRuntime background = new NineSliceRuntime(); + background.Dock(Gum.Wireframe.Dock.Fill); + background.Texture = backgroundRegion.Texture; + background.TextureAddress = TextureAddress.Custom; + background.TextureHeight = backgroundRegion.Height; + background.TextureWidth = backgroundRegion.Width; + background.TextureTop = backgroundRegion.SourceRectangle.Top; + background.TextureLeft = backgroundRegion.SourceRectangle.Left; + panel.AddChild(background); + + TextRuntime text = new TextRuntime(); + text.Text = "PAUSED"; + text.UseCustomFont = true; + text.CustomFontFile = "fonts/04b_30.fnt"; + text.FontScale = 0.5f; + text.X = 10.0f; + text.Y = 10.0f; + panel.AddChild(text); + + _resumeButton = new AnimatedButton(atlas); + _resumeButton.Text = "RESUME"; + _resumeButton.Anchor(Gum.Wireframe.Anchor.BottomLeft); + _resumeButton.Visual.X = 9.0f; + _resumeButton.Visual.Y = -9.0f; + + _resumeButton.Click += OnResumeButtonClicked; + _resumeButton.GotFocus += OnElementGotFocus; + + panel.AddChild(_resumeButton); + + AnimatedButton quitButton = new AnimatedButton(atlas); + quitButton.Text = "QUIT"; + quitButton.Anchor(Gum.Wireframe.Anchor.BottomRight); + quitButton.Visual.X = -9.0f; + quitButton.Visual.Y = -9.0f; + + quitButton.Click += OnQuitButtonClicked; + quitButton.GotFocus += OnElementGotFocus; + + panel.AddChild(quitButton); + + return panel; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/createscoretext.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/createscoretext.cs new file mode 100644 index 00000000..5f29fedf --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/createscoretext.cs @@ -0,0 +1,14 @@ +private TextRuntime CreateScoreText() +{ + TextRuntime text = new TextRuntime(); + text.Anchor(Gum.Wireframe.Anchor.TopLeft); + text.WidthUnits = DimensionUnitType.RelativeToChildren; + text.X = 20.0f; + text.Y = 5.0f; + text.UseCustomFont = true; + text.CustomFontFile = @"fonts/04b_30.fnt"; + text.FontScale = 0.25f; + text.Text = string.Format(s_scoreFormat, 0); + + return text; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/definition.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/definition.cs new file mode 100644 index 00000000..3e6f5a1e --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/definition.cs @@ -0,0 +1,18 @@ +using System; +using Gum.DataTypes; +using Gum.Managers; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Content; +using MonoGameGum; +using MonoGameGum.Forms.Controls; +using MonoGameGum.GueDeriving; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; + +namespace DungeonSlime.UI; + +public class GameSceneUI : ContainerRuntime +{ + +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/eventhandlers.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/eventhandlers.cs new file mode 100644 index 00000000..9e704b25 --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/eventhandlers.cs @@ -0,0 +1,52 @@ +private void OnResumeButtonClicked(object sender, EventArgs args) +{ + // Button was clicked, play the ui sound effect for auditory feedback. + Core.Audio.PlaySoundEffect(_uiSoundEffect); + + // Since the resume button was clicked, we need to hide the pause panel. + HidePausePanel(); + + // Invoke the ResumeButtonClick event + if(ResumeButtonClick != null) + { + ResumeButtonClick(sender, args); + } +} + +private void OnRetryButtonClicked(object sender, EventArgs args) +{ + // Button was clicked, play the ui sound effect for auditory feedback. + Core.Audio.PlaySoundEffect(_uiSoundEffect); + + // Since the retry button was clicked, we need to hide the game over panel. + HideGameOverPanel(); + + // Invoke the RetryButtonClick event. + if(RetryButtonClick != null) + { + RetryButtonClick(sender, args); + } +} + +private void OnQuitButtonClicked(object sender, EventArgs args) +{ + // Button was clicked, play the ui sound effect for auditory feedback. + Core.Audio.PlaySoundEffect(_uiSoundEffect); + + // Both panels have a quit button, so hide both panels + HidePausePanel(); + HideGameOverPanel(); + + // Invoke the QuitButtonClick event. + if(QuitButtonClick != null) + { + QuitButtonClick(sender, args); + } +} + +private void OnElementGotFocus(object sender, EventArgs args) +{ + // A ui element that can receive focus has received focus, play the + // ui sound effect for auditory feedback. + Core.Audio.PlaySoundEffect(_uiSoundEffect); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/events.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/events.cs new file mode 100644 index 00000000..fde63334 --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/events.cs @@ -0,0 +1,15 @@ +/// +/// Event invoked when the Resume button on the Pause panel is clicked. +/// +public event EventHandler ResumeButtonClick; + +/// +/// Event invoked when the Quit button on either the Pause panel or the +/// Game Over panel is clicked. +/// +public event EventHandler QuitButtonClick; + +/// +/// Event invoked when the Retry button on the Game Over panel is clicked. +/// +public event EventHandler RetryButtonClick; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/fields.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/fields.cs new file mode 100644 index 00000000..6781fe07 --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/fields.cs @@ -0,0 +1,22 @@ +// The string format to use when updating the text for the score display. +private static readonly string s_scoreFormat = "SCORE: {0:D6}"; + +// The sound effect to play for auditory feedback of the user interface. +private SoundEffect _uiSoundEffect; + +// The pause panel +private Panel _pausePanel; + +// The resume button on the pause panel. Field is used to track reference so +// focus can be set when the pause panel is shown. +private AnimatedButton _resumeButton; + +// The game over panel. +private Panel _gameOverPanel; + +// The retry button on the game over panel. Field is used to track reference +// so focus can be set when the game over panel is shown. +private AnimatedButton _retryButton; + +// The text runtime used to display the players score on the game screen. +private TextRuntime _scoreText; diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/publicmethods.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/publicmethods.cs new file mode 100644 index 00000000..f131d01f --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/gamesceneui/publicmethods.cs @@ -0,0 +1,69 @@ +/// +/// Updates the text on the score display. +/// +/// The score to display. +public void UpdateScoreText(int score) +{ + _scoreText.Text = string.Format(s_scoreFormat, score); +} + +/// +/// Tells the game scene ui to show the pause panel. +/// +public void ShowPausePanel() +{ + _pausePanel.IsVisible = true; + + // Give the resume button focus for keyboard/gamepad input. + _resumeButton.IsFocused = true; + + // Ensure the game over panel isn't visible. + _gameOverPanel.IsVisible = false; +} + +/// +/// Tells the game scene ui to hide the pause panel. +/// +public void HidePausePanel() +{ + _pausePanel.IsVisible = false; +} + +/// +/// Tells the game scene ui to show the game over panel. +/// +public void ShowGameOverPanel() +{ + _gameOverPanel.IsVisible = true; + + // Give the retry button focus for keyboard/gamepad input. + _retryButton.IsFocused =true; + + // Ensure the pause panel isn't visible. + _pausePanel.IsVisible = false; +} + +/// +/// Tells the game scene ui to hide the game over panel. +/// +public void HideGameOverPanel() +{ + _gameOverPanel.IsVisible = false; +} + +/// +/// Updates the game scene ui. +/// +/// A snapshot of the timing values for the current update cycle. +public void Update(GameTime gameTime) +{ + GumService.Default.Update(gameTime); +} + +/// +/// Draws the game scene ui. +/// +public void Draw() +{ + GumService.Default.Draw(); +} diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/slime/fields.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/slime/fields.cs new file mode 100644 index 00000000..73d9235e --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/slime/fields.cs @@ -0,0 +1,5 @@ +// Buffer to queue inputs input by player during input polling. +private Queue _inputBuffer; + +// The maximum size of the buffer queue. +private const int MAX_BUFFER_SIZE = 2; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/slime/handleinput.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/slime/handleinput.cs new file mode 100644 index 00000000..dfc27684 --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/slime/handleinput.cs @@ -0,0 +1,39 @@ +private void HandleInput() +{ + Vector2 potentialNextDirection = Vector2.Zero; + + if (GameController.MoveUp()) + { + potentialNextDirection = -Vector2.UnitY; + } + else if (GameController.MoveDown()) + { + potentialNextDirection = Vector2.UnitY; + } + else if (GameController.MoveLeft()) + { + potentialNextDirection = -Vector2.UnitX; + } + else if (GameController.MoveRight()) + { + potentialNextDirection = Vector2.UnitX; + } + + // If a new direction was input, consider adding it to the buffer + if (potentialNextDirection != Vector2.Zero && _inputBuffer.Count < MAX_BUFFER_SIZE) + { + // If the buffer is empty, validate against the current direction; + // otherwise, validate against the last buffered direction + Vector2 validateAgainst = _inputBuffer.Count > 0 ? + _inputBuffer.Last() : + _segments[0].Direction; + + // Only allow direction change if it is not reversing the current + // direction. This prevents th slime from backing into itself + float dot = Vector2.Dot(potentialNextDirection, validateAgainst); + if (dot >= 0) + { + _inputBuffer.Enqueue(potentialNextDirection); + } + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/slime/initialize.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/slime/initialize.cs new file mode 100644 index 00000000..b11543ba --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/slime/initialize.cs @@ -0,0 +1,32 @@ +/// +/// Initializes the slime, can be used to reset it back to an initial state. +/// +/// The position the slime should start at. +/// The total number of pixels to move the head segment during each movement cycle. +public void Initialize(Vector2 startingPosition, float stride) +{ + // Initialize the segment collection. + _segments = new List(); + + // Set the stride + _stride = stride; + + // Create the initial head of the slime chain. + SlimeSegment head = new SlimeSegment(); + head.At = startingPosition; + head.To = startingPosition + new Vector2(_stride, 0); + head.Direction = Vector2.UnitX; + + // Add it to the segment collection. + _segments.Add(head); + + // Set the initial next direction as the same direction the head is + // moving. + _nextDirection = head.Direction; + + // Zero out the movement timer. + _movementTimer = TimeSpan.Zero; + + // initialize the input buffer. + _inputBuffer = new Queue(MAX_BUFFER_SIZE); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/slime/move.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/slime/move.cs new file mode 100644 index 00000000..d6b42516 --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/slime/move.cs @@ -0,0 +1,48 @@ +private void Move() +{ + // Get the next direction from the input buffer if one is available + if (_inputBuffer.Count > 0) + { + _nextDirection = _inputBuffer.Dequeue(); + } + + // Capture the value of the head segment + SlimeSegment head = _segments[0]; + + // Update the direction the head is supposed to move in to the + // next direction cached. + head.Direction = _nextDirection; + + // Update the head's "at" position to be where it was moving "to" + head.At = head.To; + + // Update the head's "to" position to the next tile in the direction + // it is moving. + head.To = head.At + head.Direction * _stride; + + // Insert the new adjusted value for the head at the front of the + // segments and remove the tail segment. This effectively moves + // the entire chain forward without needing to loop through every + // segment and update its "at" and "to" positions. + _segments.Insert(0, head); + _segments.RemoveAt(_segments.Count - 1); + + // Iterate through all of the segments except the head and check + // if they are at the same position as the head. If they are, then + // the head is colliding with a body segment and a body collision + // has occurred. + for (int i = 1; i < _segments.Count; i++) + { + SlimeSegment segment = _segments[i]; + + if (head.At == segment.At) + { + if (BodyCollision != null) + { + BodyCollision.Invoke(this, EventArgs.Empty); + } + + return; + } + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/snippets/slime/usings.cs b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/slime/usings.cs new file mode 100644 index 00000000..f81e4a3a --- /dev/null +++ b/articles/tutorials/building_2d_games/23_completing_the_game/snippets/slime/usings.cs @@ -0,0 +1,6 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using MonoGameLibrary; +using MonoGameLibrary.Graphics; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/23_completing_the_game/videos/gameplay.webm b/articles/tutorials/building_2d_games/23_completing_the_game/videos/gameplay.webm new file mode 100644 index 00000000..09fb8113 Binary files /dev/null and b/articles/tutorials/building_2d_games/23_completing_the_game/videos/gameplay.webm differ diff --git a/articles/tutorials/building_2d_games/24_shaders/images/shader-pipeline.png b/articles/tutorials/building_2d_games/24_shaders/images/shader-pipeline.png new file mode 100644 index 00000000..ad55ed16 Binary files /dev/null and b/articles/tutorials/building_2d_games/24_shaders/images/shader-pipeline.png differ diff --git a/articles/tutorials/building_2d_games/24_shaders/index.md b/articles/tutorials/building_2d_games/24_shaders/index.md new file mode 100644 index 00000000..f1860e4f --- /dev/null +++ b/articles/tutorials/building_2d_games/24_shaders/index.md @@ -0,0 +1,456 @@ +--- +title: "Introduction to Shaders" +description: "Learn how to create custom visual effects using shaders in MonoGame" +--- + +In the previous chapters, we have built a complete snake-style game with animations, collision detection, audio, and scene management. While our game is fully functional, we can enhance the visual experience by implementing special effects to provide additional feedback to players. One powerful way to create these effects is through shaders. + +In this chapter, you will: + +- Understand what shaders are and how they function in MonoGame. +- Learn about different types of shaders including vertex and pixel shaders. +- Explore shader models and cross-platform considerations. +- Analyze the default shader template used in MonoGame. +- Create a custom grayscale shader for visual feedback. + +> [!IMPORTANT] +> This chapter is an introduction to shaders in MonoGame and will focus on the basic foundation of understanding how to create shader effect (*.fx*) files, loading them through the content pipeline, and using them in your game. +> +> If you want to learn more about the shader language itself, a good place to start would be the [High-level shader language (HLSL)](https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/dx-graphics-hlsl) documentation on Microsoft Learn. +> +> For inspiration on what can be achieved with shaders, check out [ShaderToy](https://www.shadertoy.com), which showcases real-time shader effects created by others. Note that ShaderToy uses [OpenGL Shading Language (GLSL)](https://www.khronos.org/opengl/wiki/OpenGL_Shading_Language) which has some syntactic differences from HLSL, but the underlying concepts and mathematics are very similar for inspiration. + +We can begin by understanding what shaders are and how they work in MonoGame. + +## Understanding Shaders + +Shaders are small programs that run directly on your graphics card (GPU) instead of your computer's main processor (CPU). While traditional game code runs on the CPU and handles things like game logic and physics, shaders run on the GPU and focus specifically on how things are drawn to the screen. This separation allows for efficient processing and enables visual effects that would be too performance-intensive to calculate on the CPU. + +Think of shaders as special instructions that tell your GPU exactly how to display each pixel or transform each vertex in your game. By customizing these instructions, you can create a wide variety of visual effects without having to change your original artwork. + +### Types of Shaders + +There are two main types of shaders that you will work with in MonoGame: + +#### Vertex Shaders + +Vertex shaders process the corners ([vertices](https://www.mathsisfun.com/geometry/vertices-faces-edges.html)) of the shapes that make up your game objects. They determine where these points should be positioned on the screen and can manipulate their positions to create effects like: + +- Waves or ripples in water +- Swaying grass or trees +- Character deformation for animations + +Vertex shaders are especially important in 3D games, but they can also be used in 2D games for special effects that involve moving or distorting sprite positions. + +> [!NOTE] +> Even in 2D games like ours, MonoGame actually draws sprites by mapping textures onto simple 3D shapes called quads (rectangles made of two triangles). Each quad has four vertices, one at each corner. While we do not normally think about these vertices in 2D development, they are still there behind the scenes, and vertex shaders process them before rendering. + +#### Pixel Shaders + +Pixel shaders (also sometimes called fragment shaders) determine the actual color of each pixel that gets drawn to the screen. After the GPU figures out which pixels need to be drawn based on your geometry, the pixel shader calculates the final color for each of those pixels. + +Pixel shaders are useful in 2D games for creating effects like: + +- Color adjustments (brightness, contrast, saturation) +- Visual filters (grayscale, sepia, negative) +- Special transitions (fades, dissolves, color shifts) +- Dynamic lighting effects + +For our Dungeon Slime game, we will focus primarily on pixel shaders since we want to create a color effect for our game over state. + +> [!NOTE] +> There are other types of shaders beyond vertex and pixel shaders, such as compute shaders, geometry shaders, and hull/domain shaders. These more advanced shader types enabled powerful features like physics simulations, procedural geometry, and complex post-processing effects. However, they are not currently supported in the standard MonoGame implementation and are beyond the scope of this beginner tutorial. As the MonoGame graphics pipeline evolves, support for these advanced shader types may be added in future versions. + +### The Shader Pipeline + +To understand how shaders work, it helps to visualize how data flow through the rendering pipeline. + +| ![Figure 24-1: Basic shader pipeline showing how data flows through the rendering process](./images/shader-pipeline.png) | +| :----------------------------------------------------------------------------------------------------------------------: | +| **Figure 24-1: Basic shader pipeline showing how data flows through the rendering process** | + +This diagram illustrates the fundamental steps of the shader pipeline: + +1. **Input Data**: The process begins with input data that is sent to the GPU to render: + 1. **Vertex Data**: The vertex data (positions, colors, etc.) that define the geometry of what is being drawn. + 2. **Texture**: The image data that will be applied to the geometry. +2. **Vertex Shader**: Processes each vertex, calculating its final position on the screen and passing the data to the next stage. +3. **Sampler**: Controls how texture data is accessed, applying [filtering](../18_texture_sampling/index.md#filtering-modes) (how pixels blend when scaled) and [addressing](../18_texture_sampling/index.md#addressing-modes) (what happens at texture edges). +4. **Pixel Shader**: Takes the transformed vertices and sampled texture data to calculate the final color of each pixel. +5. **Output**: The final rendered image that appears on your screen. + +> [!NOTE] +> When working with [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch) (2D) in MonoGame, the framework handles most of the vertex shader work automatically, which is why we will focus primarily on writing pixel shaders for visual effects. + +### Shader Languages and Cross-Platform Considerations + +MonoGame uses the [High-Level Shader Language (HLSL)](https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/dx-graphics-hlsl) for writing shader effects. **HLSL** is a C-like programming language developed by Microsoft for DirectX. As MonoGame also supports OpenGL which uses the [OpenGL Shading Language (GLSL)](https://www.khronos.org/opengl/wiki/OpenGL_Shading_Language) instead of DirectX as it needs a way to make shaders work everywhere. + +This is where MojoShader comes in. MojoShader is a library that automatically translates your **HLSL** shader code into whatever format the target platform requires (like **GLSL** for OpenGL platforms), this translation happens during the content build process when you compile your game. + +### Shader Models and Compatibility + +Different platforms support different shader capabilities, known as "shader profiles." (Also known as "Shader Models" in some circles). When writing shaders for MonoGame, you need to consider compatibility across platforms. + +MonoGame supports the following shader models when targeting DirectX platforms: + +| Vertex Shader Profile | Pixel Shader Profile | +| --------------------- | -------------------- | +| `vs_4_0_level_9_1` | `ps_4_0_level_9_1` | +| `vs_4_0_level_9_3` | `ps_4_0_level_9_3` | +| `vs_4_0` | `ps_4_0` | +| `vs_4_1` | `ps_4_1` | +| `vs_5_0` | `ps_5_0` | + +When targeting OpenGL platforms, MonoGame supports: + +| Vertex Shader Profile | Pixel Shader Profile | +| --------------------- | -------------------- | +| `vs_2_0` | `ps_2_0` | +| `vs_3_0` | `ps_3_0` | + +For maximum compatibility, it is best to target the following shader models: + +- For DirectX platforms: `vs_4_0_level_9_1` (vertex) and `ps_4_0_level_9_1` (pixel) +- For OpenGL platforms: `vs_3_0` (vertex) and `ps_3_0` (pixel) + +> [!NOTE] +> In shader model notation, "vs" stands for "vertex shader" and "ps" stands for "pixel shader". The numbers represent the version and feature level of the shader model, with higher numbers indicating more advanced capabilities. + +> [!NOTE] +> MonoGame is currently planning to upgrade its graphics pipeline to support Vulkan and DirectX 12, which will significantly enhance graphical capabilities and shader support across platforms, enabling more advanced visual effects and better performance in future versions. + +## Understanding the Default Shader Template + +When you create a new `Sprite Effect (.fx)` file in MonoGame using the MGCB Editor, it generates a default template file. To get a feel for the language used in shaders we can use this template to understand the foundation that we will build upon: + +[!code-c[](./snippets/defaultshader.fx)] + +Breaking down what each section of this template we can see: + +1. **Platform compatibility defines**: The first block created defines for different shader model versions based on the target platform the shader gets compiled for (OpenGL vs DirectX). + + [!code-c[](./snippets/defaultshader.fx?start=1&end=8)] + + > [!IMPORTANT] + > These preprocessor directives ensure your shader works across different platforms by defining appropriate shader models and semantics. When MonoGame compiles your shader: + > + > - On OpenGL platforms (macOS, Linux, etc.), it uses the `OPENGL` definition, setting shader models to `vs_3_0` and `ps_3_0`. + > - On DirectX platforms (Windows, Xbox), it uses the DirectX path, setting shader models to `vs_4_0_level_9_1` and `ps_4_0_level_9_1`. + > + > This compatibility block directly impacts which **HLSL** features you can use in your shader. To maintain cross-platform compatibility, you should restrict yourself to features available at the lowest shader model you target. Using advanced features only available in higher shader models will require additional conditional compilation blocks and platform-specific code paths. + +2. **Texture declaration**: This declares a `Texture2D` variable called `SpriteTexture` that will receive the texture being drawn by the [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch). + + [!code-c[](./snippets/defaultshader.fx?start=10&end=10)] + +3. **Sampler state**: This creates a `sampler2D` called `SpriteTextureSampler` that controls how the shader reads pixel data from the texture received from the [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch). It defines properties like [filtering](../18_texture_sampling/index.md#filtering-modes) and [addressing](../18_texture_sampling/index.md#addressing-modes) modes for texture sampling, similar to the [**SamplerState**](xref:Microsoft.Xna.Framework.Graphics.SamplerState) we discussed in [Chapter 18](../18_texture_sampling/index.md). + + [!code-c[](./snippets/defaultshader.fx?start=12&end=15)] + +4. **Vertex shader output structure**: This defines a struct called `VertexShaderOutput` with three fields; `Position`, `Color`, and `TextureCoordinates`. This struct represents the data that is passed from the **Vertex Shader** function to the **Pixel Shader** function. + + [!code-c[](./snippets/defaultshader.fx?start=17&end=22)] + + This struct defines the passing of: + + - The position of the vertex in homogeneous coordinates (x, y, z, w). + - The RGBA color of the vertex. + - The [UV coordinates for texture mapping](https://inspirationtuts.com/what-is-uv-mapping-and-unwrapping/). + + > [!TIP] + > Notice the unusual syntax like `float4 Position : SV_POSITION` where there is a colon followed by something after each variable declaration. These are called [*semantics*](https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/dx-graphics-hlsl-semantics) in HLSL and they are special labels that define how the data should be used by the graphics hardware or passed between shader stages (like from vertex shader to pixel shader). + > + > For example, `SV_POSITION` tells the system "this contains the final screen position," `COLOR0` means "this contains color data," and `TEXCOORD0` means "this contains texture coordinates." + > + > Semantics are required in HLSL to connect your shader variables with the graphics pipeline. Without them, the GPU would not know what each piece of data represents or how to use it correctly. + +5. **Pixel shader function**: `MainPS` is the main **Pixel Shader** function that determines the color of each pixel. The default implementation simply samples the texture at the current texture coordinates and multiplies it by the vertex color, which is the color value supplied in the [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Draw(Microsoft.Xna.Framework.Graphics.Texture2D,Microsoft.Xna.Framework.Vector2,System.Nullable{Microsoft.Xna.Framework.Rectangle},Microsoft.Xna.Framework.Color,System.Single,Microsoft.Xna.Framework.Vector2,System.Single,Microsoft.Xna.Framework.Graphics.SpriteEffects,System.Single)) method call. + + [!code-c[](./snippets/defaultshader.fx?start=24&end=27)] + +6. **Technique and pass**: This block defines a technique called `SpriteDrawing` with a single pass called `P0`. The pass itself defines which shader functions to use. + + [!code-c[](./snippets/defaultshader.fx?start=29&end=35)] + +> [!NOTE] +> You may have noticed that the default shader does not define a vertex shader method that would execute and return back the `VertexShaderOutput` value that is used as the input for the **Pixel Shader** function. +> +> When you are using [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch), MonoGame uses a built-in vertex shader under the hood that executes and creates this value to pass to the pixel shader function. + +### Understanding Techniques and Passes + +The `technique` and `pass` sections might seem a bit confusing at first, but they are actually quite straightforward: + +- A **technique** is like a recipe for rendering something. Each technique has a name (in this case `SpriteDrawing`) and contains one or more passes. +- A **pass** is a single step in that recipe. For simple effects, you often need just one pass (which is why the template only has `P0`), but more complex effects might use multiple passes to build up a final result. + +Think of it like baking a cake: + +- The technique is the overall cake recipe. +- Each pass is a step in that recipe (mix ingredients, bake, add frosting, etc). + +In simple shaders, such as a grayscale shader, you would only need one technique with one pass. For more complex effects like blur, you might use multiple passes: one to blur horizontally and another to blur vertically. + +> [!TIP] +> For a real-world example of a blur shader with multiple techniques and passes, take a look at the [Blur shader from the MonoGme Ship Game](https://github.com/MonoGame/MonoGame.Samples/blob/3.8.2/ShipGame/ShipGame.Core/Content/shaders/Blur.fx) sample. This shader demonstrates how visual effects can be built by combining multiple rendering passes, with separate horizontal and vertical blur passes that work together to create a final blur effect. + +The line `PixelShader = compile PS_SHADERMODEL MainPS();` simply tells the GPU which pixel shader function to use for this pass (in this case the `MainPS` function) and compiles it using the appropriate shader model defined earlier. + +## Using Shaders in MonoGame + +Now that we understand what shaders are, we can explore how to integrate them into a MonoGame project. Before implementing a shader in our game, we will first take a look at the process of loading and using shaders with [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch). + +To understand, we will start with a basic example (not for the game itself): + +### Loading Shader Effects + +Like other game assets such as textures and sounds, shader effects are loaded through the content pipeline using the [**ContentManager**](xref:Microsoft.Xna.Framework.Content.ContentManager). When loading a shader, we specify [**Effect**](xref:Microsoft.Xna.Framework.Graphics.Effect) as the target type. + +```cs +// Example of loading an effect +Effect exampleEffect = Content.Load("exampleEffect"); +``` + +You should typically load shader effects during your game's [**LoadContent**](xref:Microsoft.Xna.Framework.Game.LoadContent) method along with other game assets, and store them in class fields so they can be accessed during the [**Draw**](xref:Microsoft.Xna.Framework.Game.Draw(Microsoft.Xna.Framework.GameTime)) method. + +### Using Effects With SpriteBatch + +Once you have loaded a shader effect, applying it to your game's visuals requires integrating it with the [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch). The effect is specified during the `Begin` call, **but is actually applied during drawing operations or when `End` is called (depending on the SpriteSortMode)**. + +```cs +// Specify the effect during Begin +spriteBatch.Begin(effect: exampleEffect); + +// The effect will be applied when these draw calls are processed +spriteBatch.Draw(exampleTexture, texturePosition, Color.White); +spriteBatch.DrawString(exampleFont, "Hello World", textPosition, Color.White); + +// For most SpriteSortMode values, actual drawing with the effect happens here +spriteBatch.End(); +``` + +### Setting Effect Parameters + +Most shader effects have parameters that you can adjust to control their behavior. For example, the shader we will create for our game will have a `Saturation` parameter. You should set these parameters before the actual drawing occurs: + +```cs +// Update the parameter value +exampleEffect.Parameters["Saturation"].SetValue(0.5f); + +// Specify the effect during Begin +spriteBatch.Begin(effect: exampleEffect); + +// Draw calls will use the effect with Saturation = 0.5f when processed +``` + +## Implementing a Shader In Our Game + +Now it is time to implement a shader for our game. The shader we will create is a simple grayscale effect that can be applied when the game is paused or there is a game over to provide visual feedback to the player that the game is inactive, the background will become grey offsetting the color of the Pause menu or other prompt. + +### Creating the Shader File + +First, we need to create a new shader effect file and add it to our content project. + +1. In the *DungeonSlime* project (your main game project), open the `/Content/Content.mgcb` content project file in the **MGCB** Editor. +2. In the MGCB Editor, right-click the `Content` now and choose `Add > New Folder...`. +3. Give the new folder the name `effects` and click the `Ok` button. +4. Right-click on the new `effects` folder in the MGCB Editor and choose `Add > New Item...`. +5. Choose `Sprite Effect (.fx)` from the type list and name the file `grayscaleEffect`, then click the `Ok` button. +6. **Save the changes in the MGCB Editor the close it.** + +The steps above will create a new shader effect (*.fx*) file with the default template we discussed earlier. Now, we need to modify this template to create our grayscale effect. + +### Writing the Grayscale Shader + +In the *DungeonSlime* project (your main game project), open the `Content/effects/grayscaleEffect.fx` file that we just created in your code editor and modify it as follows: + +[!code-c[](./snippets/grayscaleeffect.fx?highlight=12-14,30-44)] + +The key modifications made to create this grayscale effect include: + +1. **Added a Parameter**: A `Saturation` parameter was added that controls the intensity of the grayscale effect. + + - When set to **0**, the image will be fully grayscale. + - When set to **1**, the image will be its original color. + - Values in between create a partial grayscale effect. + +2. **Modified the Pixel Shader**: The `MainPS` function has been updated to: + + - Sample the original color from the texture. + - Calculate a grayscale value by taking a weighted average of the RGB components. + - Create a grayscale color vector from this single brightness value. + - Use `lerp` (linear interpolation) to blend between the grayscale and original color's rgb values based on the `Saturation` parameter. + - Preserve the original alpha (transparency) value. + - Output the new color value with the grayscale effect for the pixel. + +#### Understanding the Shader Code + +The heart of the grayscale effect is this line: + +[!code-c[](./snippets/grayscaleeffect.fx?start=33&end=34)] + +This uses the [`dot`](https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/dx-graphics-hlsl-dot) function to calculate the dot product between the color's RGB values and the vector $(0.3, 0.59, 0.11)$. This effectively calculates a weighted average where: + +- Red contributes 30%. +- Green contributes 59%. +- Blue contributes 11%. + +> [!NOTE] +> These specific weights are based on how the human eye perceives brightness in different colors. Green appears brighter to us than red, which appears brighter than blue. +> +> The weighted values themselves are based on the formula that represents the luma component from the [ITU-R BT.601](https://en.wikipedia.org/wiki/Rec._601) standard, which is commonly used for converting RGB images to grayscale based on human perception where: +> +> $Y'_{601} = 0.299R' + 0.587G' + 0.114B'$ +> +> By using these weights, we get a natural looking grayscale conversion. + +Then, we use linear interpolation with the [`lerp`](https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/dx-graphics-hlsl-lerp) function to blend between this grayscale value and the original color's RGB values: + +[!code-c[](./snippets/grayscaleeffect.fx?start=39&end=41)] + +The `lerp` function blends between the first two parameters based on the third parameter (`Saturation`). When `Saturation` is **0**, we get full grayscale, when it is **1**, we get the original color. + +### Implementing the Grayscale Shader + +Now that we have our grayscale shader, we can implement it in our game when the game is paused or a game over state occurs. In the *DungeonSlime* project (your main game project), open the `Scenes/GameScene.cs` file and perform the following: + +1. First, add these fields to the `GameScene` class after the `private GameState _state` property: + + [!code-csharp[](./snippets/gamescene/fields.cs)] + +2. Next, update the `LoadContent` method to load the grayscale shader: + + [!code-csharp[](./snippets/gamescene/loadcontent.cs?highlight=30-31)] + +3. Next, update the `TogglePause` method so that when the game is paused, it sets the saturation value to **1.0f**: + + [!code-csharp[](./snippets/gamescene/togglepause.cs?highlight=19-20)] + +4. We also need to update the `GameOver` method so that when a game over state occurs, it sets the saturation value to **1.0f**: + + [!code-csharp[](./snippets/gamescene/gameover.cs?highlight=9-10)] + +5. Next, modify the `Update` method to handle the grayscale transition: + + [!code-csharp[](./snippets/gamescene/update.cs?highlight=6-17)] + +6. Finally, update the `Draw` method to apply the shader when the game is paused or in a game over state: + + [!code-csharp[](./snippets/gamescene/draw.cs?highlight=6-18)] + + > [!NOTE] + > Notice how we set the shader parameters with the current saturation value every frame before beginning the sprite batch. This is because shaders are stateless; they do not remember any values from the previous draw cycle. Each time the GPU processes a shader, it only works with the parameters provided in that specific frame. Event if the saturation value has not changed since the last frame, we still need to send it to the shader again to apply it. This is why we constantly update the shader parameters in the `Draw` method rather than only when the value is changed. + +With these changes, when the game enters a paused or game over state, the screen will gradually fade to gray using the grayscale shader effect. This provides a clear indication that the game is inactive during these states. + +| ![Figure 24-2: The game, now using a grayscale effect when paused or a game over state occurs to visually indicate that the game is inactive](./videos/gameplay.webm) | +| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 24-2: The game, now using a grayscale effect when paused or a game over state occurs to visually indicate that the game is inactive** | + +## Important Considerations + +When working with effects in [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch), there are some important points to keep in mind: + +1. Only one effect can be applied to a [**SpriteBatch.Begin**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Begin(Microsoft.Xna.Framework.Graphics.SpriteSortMode,Microsoft.Xna.Framework.Graphics.BlendState,Microsoft.Xna.Framework.Graphics.SamplerState,Microsoft.Xna.Framework.Graphics.DepthStencilState,Microsoft.Xna.Framework.Graphics.RasterizerState,Microsoft.Xna.Framework.Graphics.Effect,System.Nullable{Microsoft.Xna.Framework.Matrix}))/[**SpriteBatch.End**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.End) at a time. If you need to use multiple effects for different sprites, you will need multiple [**SpriteBatch.Begin**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Begin(Microsoft.Xna.Framework.Graphics.SpriteSortMode,Microsoft.Xna.Framework.Graphics.BlendState,Microsoft.Xna.Framework.Graphics.SamplerState,Microsoft.Xna.Framework.Graphics.DepthStencilState,Microsoft.Xna.Framework.Graphics.RasterizerState,Microsoft.Xna.Framework.Graphics.Effect,System.Nullable{Microsoft.Xna.Framework.Matrix}))/[**SpriteBatch.End**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.End) blocks. + + ```cs + // Begin sprite batch with effect. All draw calls made within this begin/end block will have the effect applied. + spriteBatch.Begin(effect: exampleEffect1) + spriteBatch.Draw(texture, position, color); + spriteBatch.End(); + + // Begins sprite batch with a different effect. All draw calls made within this begin/end block will have the specified effect applied. + spriteBatch.Begin(effect: exampleEffect2) + spriteBatch.DrawS(texture, position, color); + spriteBatch.End(); + ``` + +2. Along with #1 above, if you want to be selective and only have the effect apply to some sprites and not others, you will still need to use multiple [**SpriteBatch.Begin**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Begin(Microsoft.Xna.Framework.Graphics.SpriteSortMode,Microsoft.Xna.Framework.Graphics.BlendState,Microsoft.Xna.Framework.Graphics.SamplerState,Microsoft.Xna.Framework.Graphics.DepthStencilState,Microsoft.Xna.Framework.Graphics.RasterizerState,Microsoft.Xna.Framework.Graphics.Effect,System.Nullable{Microsoft.Xna.Framework.Matrix}))/[**SpriteBatch.End**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.End) blocks + + ```cs + // Begin sprite batch with effect. All draw calls made within this begin/end block will have the specified effect applied. + spriteBatch.Begin(effect: exampleEffect) + spriteBatch.Draw(texture, position, color); + spriteBatch.End(); + + // Begin sprite batch without effect so that draw calls made here have no effect applied. + spriteBatch.Begin(); + spriteBatch.Draw(texture, position, color); + spriteBatch.End(); + ``` + +3. Even though the effect to use is specified during the [**SpriteBatch.Begin**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Begin(Microsoft.Xna.Framework.Graphics.SpriteSortMode,Microsoft.Xna.Framework.Graphics.BlendState,Microsoft.Xna.Framework.Graphics.SamplerState,Microsoft.Xna.Framework.Graphics.DepthStencilState,Microsoft.Xna.Framework.Graphics.RasterizerState,Microsoft.Xna.Framework.Graphics.Effect,System.Nullable{Microsoft.Xna.Framework.Matrix})) call, the actual effect is not applied until all batched items are processed when [**SpriteBatch.End**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.End) is called. This means if you adjust parameter values of the effect between draw calls, **only the last parameter value set** is what is applied to all draw calls within the [**SpriteBatch.Begin**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Begin(Microsoft.Xna.Framework.Graphics.SpriteSortMode,Microsoft.Xna.Framework.Graphics.BlendState,Microsoft.Xna.Framework.Graphics.SamplerState,Microsoft.Xna.Framework.Graphics.DepthStencilState,Microsoft.Xna.Framework.Graphics.RasterizerState,Microsoft.Xna.Framework.Graphics.Effect,System.Nullable{Microsoft.Xna.Framework.Matrix})). + + ```cs + // Begin sprite batch with effect. + // Specifying the effect here is only specifying what effect to apply when batching ends + spriteBatch.Begin(effect: exampleEffect); + + // Change a parameter and draw something + exampleEffect.Parameters["ExampleParameter"].SetValue(1.0f); + spriteBatch.Draw(texture1, position, color); + + // Change the parameter to a different value and draw something else. + exampleEffect.Parameters["ExampleParameter"].SetValue(0.5f); + spriteBatch.Draw(texture2, position, color); + + // The actual effect is applied to the draw calls here, when End is called. + // This means the value of the parameter that will be used in the effect + // is the last parameter value set (0.5f) which will be applied to both + // draw calls instead of each of them having different parameter values. + spriteBatch.End(); + ``` + +4. There is an exception to #2 above. In [Chapter 06: Working with Textures](../06_working_with_textures/index.md#layer-depth), we discussed the different [**SpriteSortMode**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode) values that can be used when rendering. From this chapter, we learned that when using [**SpriteSortMode.Immediate**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode) that when a draw call is made, it is immediately flushed to the GPU and rendered to the screen, ignoring batching. This means that if you are using [**SpriteSortMode.Immediate**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode) then changing parameters between draw calls will apply the parameter change after it is made for the next draw call. + + > [!IMPORTANT] + > As mentioned in [Chapter 06](../06_working_with_textures/index.md#layer-depth), [**SpriteSortMode.Immediate**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode) can cause performance issues and should only be used when absolutely necessary, as `Immediate` mode effectively disables batching. + + ```cs + // Begins sprite batch with the effect AND intentionally specifying SpriteSortMode.Immediate + spriteBatch.Begin(effect: exampleEffect, sortMode: SpriteSortMode.Immediate); + + // Change a parameter and draw something. Since we are in immediate mode, the value of the parameter is used because the shader is immediately applied to the draw call. + exampleEffect.Parameters["ExampleParameter"].SetValue(1.0f); + spriteBatch.Draw(texture1, position, color); + + // Change the parameter to something else. Since we are in immediate mode, the new value of the parameter is used because the shader is immediately applied to the draw call. + exampleEffect.Parameters["ExampleParameter"].SetValue(0.5f); + spriteBatch.Draw(texture2, position, color); + + // Since immediate mode is used, batching is not performed so the effect was applied immediately on the draw calls above and not here during the end call. + spriteBatch.End(); + ``` + +## Conclusion + +This chapter explored the fundamentals of creating and adding a shader to MonoGame by implementing a grayscale shader effect. We covered several important concepts: + +- Shaders are specialized programs that run directly on the GPU, allowing for efficient visual effects that would be too performance-intensive to calculate on the CPU. +- Effect parameters allow runtime control of shader behavior. +- In MonoGame, shaders are written in High-Level Shader Language (HLSL) and can be loaded through the content pipeline like other game assets. +- Pixel shaders determine the color of each rendered pixel, making them ideal for effects like our grayscale shader. +- The timing of the shader application depends on the [**SpriteSortMode**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode) used with [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch), with most effects being applied during the [**SpriteBatch.End**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.End) call. + +While we only focused on a simple grayscale effect, the principles learned here can be used to start learning more about shaders and creating more complex visual effects. + +## Test Your Knowledge + +1. What is the primary difference between pixel shaders and vertex shaders in the context of 2D games? + + :::question-answer + Pixel shaders determine the color of each pixel being rendered, making them ideal for visual effects like grayscale, color tinting, and image filters. Vertex shaders manipulate the position of vertices, which can be used for effects like distortion, waves, or character deformation. In 2D games, pixel shaders are more commonly used for visual effects while vertex shaders are less frequently needed. + ::: + +2. What function is used in the shader to blend between grayscale and the original color, and how does it work? + + :::question-answer + The [`lerp`](https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/dx-graphics-hlsl-lerp) function (linear interpolation) is used to blend between grayscale and original colors. It takes three parameters: the first is the grayscale color vector, the second is the original color's RGB values, and the third is the `Saturation` parameter (between 0 and 1). When Saturation is 0, the output is fully grayscale; when it is 1, the output is the original color; values in between create a partial blend of both. + ::: + +3. Why can changing effect parameters between [**SpriteBatch.Draw**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.Draw(Microsoft.Xna.Framework.Graphics.Texture2D,Microsoft.Xna.Framework.Vector2,System.Nullable{Microsoft.Xna.Framework.Rectangle},Microsoft.Xna.Framework.Color,System.Single,Microsoft.Xna.Framework.Vector2,System.Single,Microsoft.Xna.Framework.Graphics.SpriteEffects,System.Single)) calls sometimes not work as expected, and what is the exception to this? + + :::question-answer + Changing effect parameters between draw calls typically does not work as expected because the effect is actually applied during [**SpriteBatch.End**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch.End), not during the draw calls. The last parameter value set before end is applied to all draw calls in the batch. The exception is when using [**SpriteSortMode.Immediate**](xref:Microsoft.Xna.Framework.Graphics.SpriteSortMode), which causes each draw call to be processed immediately rather than batched, allowing parameter changes to be applied between individual draw calls. + ::: diff --git a/articles/tutorials/building_2d_games/24_shaders/snippets/defaultshader.fx b/articles/tutorials/building_2d_games/24_shaders/snippets/defaultshader.fx new file mode 100644 index 00000000..4e2fbdac --- /dev/null +++ b/articles/tutorials/building_2d_games/24_shaders/snippets/defaultshader.fx @@ -0,0 +1,35 @@ +#if OPENGL + #define SV_POSITION POSITION + #define VS_SHADERMODEL vs_3_0 + #define PS_SHADERMODEL ps_3_0 +#else + #define VS_SHADERMODEL vs_4_0_level_9_1 + #define PS_SHADERMODEL ps_4_0_level_9_1 +#endif + +Texture2D SpriteTexture; + +sampler2D SpriteTextureSampler = sampler_state +{ + Texture = ; +}; + +struct VertexShaderOutput +{ + float4 Position : SV_POSITION; + float4 Color : COLOR0; + float2 TextureCoordinates : TEXCOORD0; +}; + +float4 MainPS(VertexShaderOutput input) : COLOR +{ + return tex2D(SpriteTextureSampler,input.TextureCoordinates) * input.Color; +} + +technique SpriteDrawing +{ + pass P0 + { + PixelShader = compile PS_SHADERMODEL MainPS(); + } +}; diff --git a/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/draw.cs b/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/draw.cs new file mode 100644 index 00000000..dd4ed215 --- /dev/null +++ b/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/draw.cs @@ -0,0 +1,34 @@ +public override void Draw(GameTime gameTime) +{ + // Clear the back buffer. + Core.GraphicsDevice.Clear(Color.CornflowerBlue); + + if (_state != GameState.Playing) + { + // We are in a game over state, so apply the saturation parameter. + _grayscaleEffect.Parameters["Saturation"].SetValue(_saturation); + + // And begin the sprite batch using the grayscale effect. + Core.SpriteBatch.Begin(samplerState: SamplerState.PointClamp, effect: _grayscaleEffect); + } + else + { + // Otherwise, just begin the sprite batch as normal. + Core.SpriteBatch.Begin(samplerState: SamplerState.PointClamp); + } + + // Draw the tilemap + _tilemap.Draw(Core.SpriteBatch); + + // Draw the slime. + _slime.Draw(); + + // Draw the bat. + _bat.Draw(); + + // Always end the sprite batch when finished. + Core.SpriteBatch.End(); + + // Draw the UI + _ui.Draw(); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/fields.cs b/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/fields.cs new file mode 100644 index 00000000..acd8e25f --- /dev/null +++ b/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/fields.cs @@ -0,0 +1,8 @@ +// The grayscale shader effect. +private Effect _grayscaleEffect; + +// The amount of saturation to provide the grayscale shader effect +private float _saturation = 1.0f; + +// The speed of the fade to grayscale effect. +private const float FADE_SPEED = 0.02f; \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/gameover.cs b/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/gameover.cs new file mode 100644 index 00000000..6abf1269 --- /dev/null +++ b/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/gameover.cs @@ -0,0 +1,11 @@ +private void GameOver() +{ + // Show the game over panel + _ui.ShowGameOverPanel(); + + // Set the game state to game over + _state = GameState.GameOver; + + // Set the grayscale effect saturation to 1.0f; + _saturation = 1.0f; +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/loadcontent.cs b/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/loadcontent.cs new file mode 100644 index 00000000..5919ff4f --- /dev/null +++ b/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/loadcontent.cs @@ -0,0 +1,32 @@ +public override void LoadContent() +{ + // Create the texture atlas from the XML configuration file + TextureAtlas atlas = TextureAtlas.FromFile(Core.Content, "images/atlas-definition.xml"); + + // Create the tilemap from the XML configuration file. + _tilemap = Tilemap.FromFile(Content, "images/tilemap-definition.xml"); + _tilemap.Scale = new Vector2(4.0f, 4.0f); + + // Create the animated sprite for the slime from the atlas. + AnimatedSprite slimeAnimation = atlas.CreateAnimatedSprite("slime-animation"); + slimeAnimation.Scale = new Vector2(4.0f, 4.0f); + + // Create the slime + _slime = new Slime(slimeAnimation); + + // Create the animated sprite for the bat from the atlas. + AnimatedSprite batAnimation = atlas.CreateAnimatedSprite("bat-animation"); + batAnimation.Scale = new Vector2(4.0f, 4.0f); + + // Load the bounce sound effect for the bat + SoundEffect bounceSoundEffect = Content.Load("audio/bounce"); + + // Create the bat + _bat = new Bat(batAnimation, bounceSoundEffect); + + // Load the collect sound effect + _collectSoundEffect = Content.Load("audio/collect"); + + // Load the grayscale effect + _grayscaleEffect = Content.Load("effects/grayscaleEffect"); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/togglepause.cs b/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/togglepause.cs new file mode 100644 index 00000000..4fca024c --- /dev/null +++ b/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/togglepause.cs @@ -0,0 +1,22 @@ +private void TogglePause() +{ + if (_state == GameState.Paused) + { + // We're now unpausing the game, so hide the pause panel + _ui.HidePausePanel(); + + // And set the state back to playing + _state = GameState.Playing; + } + else + { + // We're now pausing the game, so show the pause panel + _ui.ShowPausePanel(); + + // And set the state to paused + _state = GameState.Paused; + + // Set the grayscale effect saturation to 1.0f; + _saturation = 1.0f; + } +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/update.cs b/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/update.cs new file mode 100644 index 00000000..0786f32e --- /dev/null +++ b/articles/tutorials/building_2d_games/24_shaders/snippets/gamescene/update.cs @@ -0,0 +1,39 @@ +public override void Update(GameTime gameTime) +{ + // Ensure the UI is always updated + _ui.Update(gameTime); + + if (_state != GameState.Playing) + { + // The game is in either a paused or game over state, so + // gradually decrease the saturation to create the fading grayscale. + _saturation = Math.Max(0.0f, _saturation - FADE_SPEED); + + // If its just a game over state, return back + if (_state == GameState.GameOver) + { + return; + } + } + + // If the pause button is pressed, toggle the pause state + if (GameController.Pause()) + { + TogglePause(); + } + + // At this point, if the game is paused, just return back early + if (_state == GameState.Paused) + { + return; + } + + // Update the slime; + _slime.Update(gameTime); + + // Update the bat; + _bat.Update(gameTime); + + // Perform collision checks + CollisionChecks(); +} \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/24_shaders/snippets/grayscaleeffect.fx b/articles/tutorials/building_2d_games/24_shaders/snippets/grayscaleeffect.fx new file mode 100644 index 00000000..2454ef19 --- /dev/null +++ b/articles/tutorials/building_2d_games/24_shaders/snippets/grayscaleeffect.fx @@ -0,0 +1,53 @@ +#if OPENGL + #define SV_POSITION POSITION + #define VS_SHADERMODEL vs_3_0 + #define PS_SHADERMODEL ps_3_0 +#else + #define VS_SHADERMODEL vs_4_0_level_9_1 + #define PS_SHADERMODEL ps_4_0_level_9_1 +#endif + +Texture2D SpriteTexture; + +// A value between 0 and 1 that controls the intensity of the grayscale effect. +// 0 = full color, 1 = full grayscale. +float Saturation = 1.0; + +sampler2D SpriteTextureSampler = sampler_state +{ + Texture = ; +}; + +struct VertexShaderOutput +{ + float4 Position : SV_POSITION; + float4 Color : COLOR0; + float2 TextureCoordinates : TEXCOORD0; +}; + +float4 MainPS(VertexShaderOutput input) : COLOR +{ + // Sample the texture + float4 color = tex2D(SpriteTextureSampler, input.TextureCoordinates) * input.Color; + + // Calculate the grayscale value based on human perception of colors + float grayscale = dot(color.rgb, float3(0.3, 0.59, 0.11)); + + // create a grayscale color vector (same value for R, G, and B) + float3 grayscaleColor = float3(grayscale, grayscale, grayscale); + + // Linear interpolation between he grayscale color and the original color's + // rgb values based on the saturation parameter. + float3 finalColor = lerp(grayscale, color.rgb, Saturation); + + // Return the final color with the original alpha value + return float4(finalColor, color.a); +} + +technique SpriteDrawing +{ + pass P0 + { + PixelShader = compile PS_SHADERMODEL MainPS(); + } +}; diff --git a/articles/tutorials/building_2d_games/24_shaders/videos/gameplay.webm b/articles/tutorials/building_2d_games/24_shaders/videos/gameplay.webm new file mode 100644 index 00000000..11ce0b53 Binary files /dev/null and b/articles/tutorials/building_2d_games/24_shaders/videos/gameplay.webm differ diff --git a/articles/tutorials/building_2d_games/25_packaging_game/index.md b/articles/tutorials/building_2d_games/25_packaging_game/index.md new file mode 100644 index 00000000..49a36f2a --- /dev/null +++ b/articles/tutorials/building_2d_games/25_packaging_game/index.md @@ -0,0 +1,535 @@ +--- +title: "Chapter 25: Packaging Your Game for Distribution" +description: "Learn how to package your game for distribution across Windows, macOS, and Linux platforms." +--- + +After all of our work creating Dungeon Slime, we need to prepare the game for distribution to players. Properly packaging your game ensure it runs correctly on different platforms without requiring players to have development tools installed. + +In this chapter you will: + +- Learn how to prepare your game for release. +- Package your game for Windows, macOS, and Linux platforms. +- Create platform-specific distributions with appropriate configurations. +- Understand important publishing parameters and their impact on game performance. +- Address common cross-platform distribution challenges. +- Learn about third-party tools that can automate the packaging process. + +## Understanding Game Packaging + +When developing with MonoGame, you are working in a .NET environment that abstracts away many platform-specific details. However, when distributing your game to players, you need to ensure they can run it without installing the .NET runtime or other development dependencies. + +### Self-Contained Deployments + +The recommended approach for distributing MonoGame games is to use self-contained deployments. This approach packages your game with all necessary .NET dependencies, resulting in a larger distribution but ensuring your game runs without requiring players install additional runtimes. + +A self-contained deployment offers several advantages: + +- Players can run your game without installing the .NET runtime. +- Your game will always use the exact version of the runtime it was developed with. +- Distribution is simplified with fewer external dependencies. + +The main trade-off is a larger distribution size compared to framework-dependent deployments, but this is usually worth it for the improved player experience. + +## Preparing Your Game for Release + +Before packaging your game for distribution, you should take some preparatory steps: + +1. **Set Release Configuration**: Ensure your build configuration is set to "Release" rather than "Debug" for better performance and smaller executable size. +2. **Update Game Information**: Verify your game's title, version, and other information in the project's properties file (`.csproj`). +3. **Final Testing**: Perform thorough testing in Release mode ot catch any issue that might not appear in Debug mode. +4. **Asset Optimization**: Consider optimizing larger content files to reduce the final package size. + +## Platform-Specific Packaging + +Now that we understand the general packaging concepts, we will explore how to create distributions for Windows, macOS, and Linux. Each platform has specific requirements and tooling that we will need to navigate. Choose the instructions below based on the platform you are using. + +> [!IMPORTANT] +> The packaging instructions for each platform are designed to be executed on that same platform. This is because each operating system provides specific tools needed for proper packaging (like `lipo` on macOS or `permission settings` on Unix-based systems). When building on Windows for macOS or Linux, the executable permissions cannot be set since Windows lacks these concepts. +> +> While you will need access to each platform for the steps below, do not worry if you do not have all these systems available. At the end of this chapter, third party libraries provided by MonoGame community members are included that can automate these processes for you without requiring you to own each type of machine. + +### [Windows](#tab/windows) + +Windows is the most straightforward platform to target since MonoGame development typically happens on Windows machines. + +#### Building for Windows + +To create a self-contained application for Window, open a new command prompt window in the same folder as the as the main game project (in our case the folder with the `DungeonSlime.csproj` file) and execute the following .NET CLI command: + +```sh +dotnet publish -c Release -r win-x64 -p:PublishReadyToRun=false -p:TieredCompilation=false --self-contained +``` + +This command specifies: + +- `-c Release`: Builds in Release configuration for better performance. +- `-r win-x64`: Targets 64-bit Windows platforms. +- `-p:PublishReadyToRun=false`: Disables ReadyToRun compilation (explained later). +- `-p:TieredCompilation=false`: Disables Tiered Compilation (explained later). +- `--self-contained`: Includes the .NET runtime in the package. + +The output will be placed in a directory like `bin/Release/net8.0/win-x64/publish/`, relative to the game's `.csproj` file. This directory will contain the executable and all necessary files to run your game. + +> [!NOTE] +> If your base game project is created with **dotnet 9.0** (which at the time of writing is the default), the above folder will be more like `bin/Release/net9.0/win-x64/publish/`, just so you are aware. +> Noting the change in folder from `net8.0` to `net9.0`. + +#### Creating a Windows Distribution + +Once you have created a build for Windows, to create a Windows distribution, you can simply: + +1. Zip the entire contents of the publish folder. +2. Distribute the ZIP file to your players. +3. Players can extract the ZIP and run the executable directly. + +> [!NOTE] +> If you are using the WindowsDX platform target, players may need to install the [DirectX June 2010 Runtime](https://www.microsoft.com/en-us/download/details.aspx?id=8109) for audio and gamepad support. If you are targeting this platform, consider including this information in your game's documentation. + +### [macOS](#tab/macOS) + +Packaging for macOS requires creating an **Application Bundle** (`.app`), which is a directory structure that macOS recognizes as an application. + +#### Building for macOS + +For macOS, you will need to build for both the Intel (x64) and Apple Silicon (arm64) to support all modern mac computers. Open a new terminal window in the same folder as the `DungeonSlime.csproj` file (the main game project). + +> [!TIP] +> The following sections will guide you through several terminal commands that build on each other. It is best to use a single terminal window located in your projects root directory (where the `DungeonSlime.csproj` file is) for all of these steps to ensure paths remain consistent. + +First, to create the Intel (x64) self contained application, execute the following .NET CLI command in the terminal: + +```sh +dotnet publish -c Release -r osx-x64 -p:PublishReadyToRun=false -p:TieredCompilation=false --self-contained +``` + +This command specifies: + +- `-c Release`: Builds in Release configuration for better performance. +- `-r osx-x64`: Targets Intel (x64) macOS platforms. +- `-p:PublishReadyToRun=false`: Disables ReadyToRun compilation (explained later). +- `-p:TieredCompilation=false`: Disables Tiered Compilation (explained later). +- `--self-contained`: Includes the .NET runtime in the package. + +The output from this command will be placed in a directory like `bin/Release/net8.0/osx-x64/publish/`, relative to the game's `.csproj` file. + +> [!NOTE] +> If your base game project is created with **dotnet 9.0** (which at the time of writing is the default), the above folder will be more like `bin/Release/net9.0/osx-x64/publish/`, just so you are aware. +> Noting the change in folder from `net8.0` to `net9.0`. + +Next, to create the Apple Silicon (arm64) self contained application for macOS, in the same terminal window, execute the following .NET CLI command: + +```sh +dotnet publish -c Release -r osx-arm64 -p:PublishReadyToRun=false -p:TieredCompilation=false --self-contained +``` + +The only difference in this command is the use of `-r osx-arm64` which specifies to target the Apple Silicon (arm64) macOS platform. + +The output from this command will be placed in a directory like `bin/Release/net8.0/osx-arm64/publish/`, relative to the game's `.csproj` file. + +#### Creating a macOS Application Bundle + +With the Intel (x64) and Apple Silicon (arm64) builds completed, we can now create the macOS **Application Bundle**. macOS applications follow a very specific directory structure: + +```sh +YourGame.app/ +├── Contents/ +│ ├── Info.plist +│ ├── MacOS/ +│ │ └── YourGame +│ └── Resources/ +│ ├── Content/ +│ └── YourGame.icns +``` + +To create this structure, from the same terminal window: + +1. First, create the folder structure by executing the following commands: + + ```sh + mkdir -p bin/Release/DungeonSlime.app/Contents/MacOS/ + mkdir -p bin/Release/DungeonSlime.app/Contents/Resources/Content + ``` + + > [!NOTE] + > The `mkdir -p` command creates directories including any necessary parent directories. The `-p` flag ensures all intermediate directories are created without error if they do not exist yet. + +2. Copy all files from the Intel (x64) build to the MacOS directory. This ensures all the required dependencies are included. To do this, execute the following command: + + ```sh + cp -R bin/Release/net8.0/osx-x64/publish/* bin/Release/DungeonSlime.app/Contents/MacOS/ + ``` + + > [!NOTE] + > This copies all files from the publish directory, including the executable, all dependent `.dll` files, and the `Content` directory that contains your game assets. + +3. Replace the executable with a universal binary that works on both Intel and Apple Silicon Macs. To do this, execute the following command: + + ```sh + lipo -create bin/Release/net8.0/osx-arm64/publish/DungeonSlime bin/Release/net8.0/osx-x64/publish/DungeonSlime -output bin/Release/DungeonSlime.app/Contents/MacOS/DungeonSlime + ``` + + > [!NOTE] + > The `lipo` command is a macOS utility that works with multi-architecture binaries. Here, it combines the Intel (x64) and Apple Silicon (arm64) executables into a single "universal binary" that can run natively on both Apple Silicon and Intel processor based Macs. + +4. Move the Content directory from the MacOS directory to the Resources directory, following macOS application bundle conventions. To do this, execute the following command: + + ```sh + mv bin/Release/DungeonSlime.app/Contents/MacOS/Content bin/Release/DungeonSlime.app/Contents/Resources/ + ``` + + > [!NOTE] + > This moves the `Content` directory to the expected location for resources in a macOS application bundles. + +5. Create a new file called `Info.plist` in the `Contents` directory of the application bundle with the following command: + + ```sh + touch bin/Release/DungeonSlime.app/Contents/Info.plist + ``` + + > [!NOTE] + > The `touch` command creates an empty file if it does not exist or updates the modification time if it does exist. We are using it here to create a blank file that we will populate with content in the next step. + +6. Open the `Info.plist` file you just created in a text editor and add the following content to the file and save it. + + [!code-xml[](./snippets/info.plist?highlight=8,10,12,16,30)] + + > [!NOTE] + > The `Info.plist` file is a critical component of any macOS application bundle. It contains essential metadata that macOS uses to: + > + > - Identify the application (bundle identifier, name, version). + > - Display the application correctly in Finder and the Dock. + > - Associate the correct icon with the application. + > - Define the application's capabilities and requirements. + > - Provide copyright and developer information. + > + > Without a properly formatted `Info.plist` file, macOS would not recognize your game as a valid application, and users would be unable to launch it through normal means. + + > [!TIP] + > The highlighted sections in the `Info.plist` file need to be customized for your game: + > - Replace all instances of "DungeonSlime" with your game's name. + > - The `CFBundleIconFile` value (line 10) must exactly match the name of your `.icns` file that we will create in the next step. + > - Update the bundle identifier on line 12 with your domain. + > - Modify the copyright information on line 30 as needed. + > + > Getting these values right, especially the icon filename, ensures your game appears correctly on macOS. + > + > For more information on the `Info.plist` manifest file, refer to the [About Info.plist Keys and Values](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Introduction/Introduction.html) Apple Developer documentation. + + +7. Next, create the application bundle `.icns` icon file. To do this, perform the following: + + 1. First, you will need a `.png` file that can be used to create the icon set for the final `.icns` output. If you already have a `.png` icon for your game, ensure it is in the root of the main project directory and is named `Icon.png`. If you do not have one already prepared, you can use the `Icon.bmp` that was generated in the root of the main project directory when you initially created the project. However, it will need to be converted to a `.png` first. To do this, execute the following command: + + ```sh + sips -s format png Icon.bmp --out Icon.png + ``` + + > [!NOTE] + > `sips` (Scriptable Image Processing System) is a command line tool in macOS for image manipulation. Here we are using it to convert a `.bmp` to a `.png`. In a moment, we will also use it to resize the `.png` into different icon sizes required for the application bundle. + + 2. Next, create a directory that we can output each of the generated `.png` icon files to for the icon set. Execute the following command: + + ```sh + mkdir -p bin/Release/DungeonSlime.iconset + ``` + + 3. Now we use the `sips` command to generate the icon for each size required for a mac app bundle. Each size generated is neccessary for different display scenarios in macOS (Dock, Finder, etc.). To do this, execute the following commands: + + ```sh + sips -z 16 16 Icon.png --out bin/Release/DungeonSlime.iconset/icon_16x16.png + sips -z 32 32 Icon.png --out bin/Release/DungeonSlime.iconset/icon_16x16@2x.png + sips -z 32 32 Icon.png --out bin/Release/DungeonSlime.iconset/icon_32x32.png + sips -z 64 64 Icon.png --out bin/Release/DungeonSlime.iconset/icon_32x32@2x.png + sips -z 128 128 Icon.png --out bin/Release/DungeonSlime.iconset/icon_128x128.png + sips -z 256 256 Icon.png --out bin/Release/DungeonSlime.iconset/icon_128x128@2x.png + sips -z 256 256 Icon.png --out bin/Release/DungeonSlime.iconset/icon_256x256.png + sips -z 512 512 Icon.png --out bin/Release/DungeonSlime.iconset/icon_256x256@2x.png + sips -z 512 512 Icon.png --out bin/Release/DungeonSlime.iconset/icon_512x512.png + sips -z 1024 1024 Icon.png --out bin/Release/DungeonSlime.iconset/icon_512x512@2x.png + ``` + + 4. Finally, combine all of the generated icons for the icon set into a `.icns` file. To do this, execute the following: + + ```sh + iconutil -c icns bin/Release/DungeonSlime.iconset --output bin/Release/DungeonSlime.app/Contents/Resources/DungeonSlime.icns + ``` + + > [!NOTE] + > `iconutil` is a command line tool in macOS used to convert icon sets into a single high-resolution `.icns` file. + + > [!TIP] + > After creating the `.icns` file using the above command, if you open the folder in Finder with `DungeonSlime.app` and it shows a blank square as the icon instead of the one you just created, right-click on `DungeonSlime.app` and choose `Get Info` from the context menu. This will force it to do a refresh and show the icon properly. After doing this, if the icon still does not show, then you need to double check that the `CFBundleIconFile` value in the `Info.plist` is named **exactly** the same as the `.icns` file that was created (minus the extension). + +8. Set executable permissions for the game executable. To do this, execute the following command: + + ```sh + chmod +x bin/Release/DungeonSlime.app/Contents/MacOS/DungeonSlime + ``` + + > [!NOTE] + > the `chmod +x` command changes the file permissions to make it executable. Without this step, macOS would not be able to run the application. + +#### Distributing for macOS + +For macOS distribution: + +1. Archive the application bundle using the `tar.gz` archive format to preserve the executable permissions that were set. To do this, execute the following command in the same terminal window: + + ```sh + tar -czf DungeonSlime-osx.tar.gz -C bin/Release/DungeonSlime.app + ``` + + > [!NOTE] + > The `tar` command creates an archive file: + > - `-c` creates a new archive. + > - `-z` compresses the archive using gzip. + > - `-f` specifies the filename to create + > - `-C` changes to the specified directory before adding files. + > + > Unlike **ZIP** files, the `tar.gz` format preserves Unix file permissions, which is crucial for maintaining the executable permission we set in the previous steps. + +2. Distribute the `tar.gz` archive file to players. +3. Players can extract the `tar.gz` archive file and run the application bundle to play the game. + +### [Linux](#tab/linux) + +Linux packaging is relatively straightforward, but requires attention to ensure executable permission are set. + +#### Building for Linux + +To create a self-contained application for Linux, open a new Terminal window in the same folder as the `DungeonSlime.csproj` file (your main game project folder) and execute the following .NET CLI command: + +```sh +dotnet publish -c Release -r linux-x64 -p:PublishReadyToRun=false -p:TieredCompilation=false --self-contained +``` + +- `-c Release`: Builds in Release configuration for better performance. +- `-r linux-x64`: Targets 64-bit Linux platforms. +- `-p:PublishReadyToRun=false`: Disables ReadyToRun compilation (explained later). +- `-p:TieredCompilation=false`: Disables Tiered Compilation (explained later). +- `--self-contained`: Includes the .NET runtime in the package. + +The output will be placed in a directory like `bin/Release/net8.0/linux-x64/publish`, relative to the `DungeonSlime.csproj` file. This folder will contain the executable and all necessary files to run the game. + +> [!NOTE] +> If your base game project is created with **dotnet 9.0** (which at the time of writing is the default), the above folder will be more like `bin/Release/net9.0/linux-x64/publish/`, just so you are aware. +> Noting the change in folder from `net8.0` to `net9.0`. + +#### Creating a Linux Distribution + +Once you have created a build for Linux, to create a distributable archive: + +1. Ensure the main executable has proper execute permissions by executing the following command in the same terminal window: + + ```sh + chmod +x bin/Release/net8.0/linux-x64/publish/DungeonSlime + ``` + + > [!NOTE] + > the `chmod +x` command changes the file permissions to make it executable. Without this step, Linux would not be able to run the application. + +2. Package the game using the `tar.gz` archive format to preserve executable permissions by executing the following command: + + ```sh + tar -czf DungeonSlime-linux-x64.tar.gz -C bin/Release/net8.0/linux-x64/publish/ + ``` + + > [!NOTE] + > The `tar` command creates an archive file: + > - `-c` creates a new archive. + > - `-z` compresses the archive using gzip. + > - `-f` specifies the filename to create + > - `-C` changes to the specified directory before adding files. + > + > Unlike **ZIP** files, the `tar.gz` format preserves Unix file permissions, which is crucial for maintaining the executable permission we set in the previous step. + +--- + +## Important .NET Publishing Parameters + +When publishing your game, there are several .NET parameters that can significantly impact the performance of the game. In the above sections, these are all set to the recommended values, however, we will examine them in detail below. + +### ReadyToRun (R2R) + +ReadyToRun is a feature in .NET that pre-compiles code to improve startup time. This sounds like a good thing on paper, however, for games, it can lead to micro-stutters during gameplay. + +This happens because ReadyToRun-compiled code is initially of lower quality, and the Just-In-Time (JIT) compiler will trigger periodically to optimize the code further. These optimization passes can cause visible stutters in the game. + +For games, it is recommended to disable ReadyToRun by setting `-p:PublishReadyToRun=false` in your publish command, which we have already included in our examples. + +For more information on ReadyToRun, refer to the [ReadyToRun deployment overview](https://learn.microsoft.com/en-us/dotnet/core/deploying/ready-to-run) documentation on Microsoft Learn + +### Tiered Compilation + +Tiered compilation is another .NET feature that works similarly to ReadyToRun. It initially compiles code quickly at a lower optimization level, then recompiles frequently-used methods with higher optimization. + +While this improves application startup time, it can also cause stutters during gameplay as methods are recompiled. It is recommended to disable tiered compilation by setting `-p:TieredCompilation=false` in your publish command, which we have already included in our examples. + +For more information on Tiered Compilation, refer to the [Tiered compilation](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-core-3-0#tiered-compilation) section on Microsoft Learn. + +### Native AOT (Ahead-of-Time) Compilation + +Native AOT compilation (specified with `-p:PublishAot=tru`) compiles your entire application to native code at build time, eliminating the need for JIT compilation during runtime. This can provide better performance and a smaller distribution size. + +However, AOT has limitations: + +1. No support for runtime reflection. +2. No runtime code generation. +3. Some third-party libraries your game uses may not be compatible. + +For MonoGame game, AOT can work well if you avoid these limitations. + +> [!NOTE] +> Native AoT is recommended for mobile platforms due to its performance benefits and smaller binary size, which are important for mobile devices with limited resources. Additionally, it is mandatory when targeting console platforms (Xbox, PlayStation, Switch) as these platforms typically do not support JIT compilation for security and performance reasons. + +For more information on Native AOT, refer to the [Native AOT deployment overview](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/?tabs=windows%2Cnet8) documentation on Microsoft Learn. + +### Trimming + +Trimming (specified with `-p:Trimming:true`) removes unused code from your distribution to reduce size. It is automatically enabled when using AOT. + +While trimming can significantly reduce your game's size, it may remove types that appear unused but are accessed indirectly through reflection or generics causing runtime errors. + +> [!IMPORTANT] +> Trimming can cause issues with content pipeline extensions that are used at runtime. When the compiler cannot detect that certain types are used (especially with reflection or generic collections), thy might be trimmed away, resulting in "type not found" exceptions when loading content. +> +> If you encounter runtime exceptions about missing types when loading content with trimming enabled, you can resolve this by ensuring the compiler recognizes the types being uset at runtime by making the following call: +> +> ```cs +> ContentTypeReaderManager.AddTypeCreator(typeof(ReflectiveReader).FullName, () => new ReflectiveReader()) +> ``` +> +> Where `ReaderType` is the `ContentTypeReader` of the content pipeline extension to be preserved. This call should be made somewhere in your code before loading content that uses these types. + +For more information on Trimming, refer to the [Trim self-contained applications](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trim-self-contained) documentation on Microsoft Learn. + +### Single File Publishing + +Single file publishing packages your entire application into a single executable. While this sounds convenient, it is essentially a self-extracting archive that extracts to a temporary directory at runtime. + +This can significantly increase startup time for larger games and may fail on system with restricted permissions of limited storage. For this reason, it is not recommended to use this option for games. + +For more information on Single File Publishing, refer to the [Create a single file for application deployment](https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/overview?tabs=cli) documentation on Microsoft Learn. + +## Cross-Platform Considerations + +When distributing your games across multiple platforms, be aware of these additional considerations: + +### File Paths + +Different operating systems use different path separators (Windows uses backslashes, macOS and Linux use forward slashes). Always use `Path.Combine` in your code rather than hardcoding path separators. + +```cs +// Incorrect approach - will fail on some platforms +string path = "Content\\images\\atlas-definition.xml"; + +// Correct approach, works on all platforms +string path = Path.Combine("Content", "images", "atlas-definition.xml"); +``` + +### Case Sensitivity + +Windows is case-insensitive for filenames, but macOS and Linux are case-sensitive. Ensure your asset references use the exact case that matches your files for maximum compatibility. + +```cs +// If the content path on disk is: +// images/Atlas.xnb + +// On Windows, this would work fine since windows is case-insensitive. +// On macOS and Linux, this would fail since they are case-sensitive. +Texture2D text = Content.Load("images/atlas"); +``` + +### External Dependencies + +Try to minimize external dependencies. If your game requires additional libraries or runtimes, document these requirements clearly for players. + +> [!NOTE] +> When publishing to distribution platforms and app stores (such as Steam, Epic Game Sore, App Store, or Google Play), you are typically required to disclose all external dependencies in your privacy policy or a dedicate dependencies section. This includes third-party libraries, analytics tools, and any software components that your game depends on. +> +> Check specific requirements for each distribution platform you plant to target, as well as requirements by third-party libraries for using them, as disclosure requirements may vary. + +## Mobile Platforms + +While this tutorial series has focused on creating a 2D game for desktop platforms, MonoGame also offers support for mobile development on Android and iOS. The game we have built throughout this series could be adapted for touch controls and distributed through mobile app stores with additional work. + +Mobile deployment involves several considerations beyond those of desktop platforms: + +- App store submission process and platform-specific requirements. +- Platform-specific signing and certification procedures. +- Extensive device compatibility testing across various screen sizes and hardware. +- Optimization of touch input controls (replacing our keyboard and gamepad input). +- Power consumption management and performance optimization for mobile hardware. + +For the Dungeon Slime game, adapting to mobile would require: + +- Implementing touch controls to replace the keyboard/gamepad movement. +- Potentially rethinking game mechanics to suit mobile play patterns. + +> [!NOTE] +> Mobile deployment for MonoGame games is significantly more complex than desktop deployment and typically requires platform-specific development environments (Android Studio for Android and Xcode for iOS). A comprehensive guide to mobile deployment will be covered in a future tutorial. + +If you are interested in extending the Dungeon Slime game, or future games, to mobile platforms after completing this tutorial series, these resources provide a good starting point: + +- [Android Deployment Guide](https://learn.microsoft.com/en-us/previous-versions/xamarin/android/deploy-test/publishing/) +- [iOS App Store Distribution](https://learn.microsoft.com/en-us/previous-versions/xamarin/ios/deploy-test/app-distribution/app-store-distribution/publishing-to-the-app-store?tabs=windows) + +## Third-Party Packaging Tools + +While the platform-specific packaging steps outlined in this chapter give you complete control over the distribution process, they require multiple commands and potentially access to different operating system. Fortunately, the MonoGame community has developed several tools that can automate these packaging steps across platforms. + +### GameBundle + +[GameBundle](https://github.com/Ellpeck/GameBundle) is a .NET command-line tool created by [Ellpeck](https://github.com/Ellpeck) that simplifies packaging MonoGame and other .NET applications into several distributable formats. This tool can automatically bundle your game for Windows, Linux, and macOS platforms, create applications bundles for macOS, and handle various packaging configurations with a single command. + +For more information about GameBundle, including installation and usage instructions, visit the [official repository on GitHub](https://github.com/Ellpeck/GameBundle) + +### MonoPack + +[MonoPack](https://github.com/shyfox-studio/MonoPack) is a .NET command-line tool created by [ShyFox Studio](https://github.com/shyfox-studio) designed specifically for MonoGame projects. According to its documentation, "MonoPack is a dotnet tool used for MonoGame projects to package the game for Windows, Linux, and/or macOS". + +Key features include: + +- Cross-platform packaging capabilities (build for any OS from any OS). +- Automatic creation of macOS application bundles. +- Appropriate compression formats for each target platform for distribution. + +For more information about MonoPack, including installation and usage instructions, visit the [official repository on GitHub](https://github.com/shyfox-studio/MonoPack) + +## Conclusion + +In this chapter, you learned how to package your MonoGame project for distribution across Windows, macOS, and Linux platforms. You now understand how to create self-contained deployments for each target platform, the impact of various .NET publishing options on game performance, and important cross-platform considerations. + +Whether you choose to use the manual platform-specific packaging steps or automate the process with tools like [GameBundle](#gamebundle) or [MonoPack](#monopack), you now have the knowledge to ensure your game runs smoothly for players across different platforms without requiring them to install additional dependencies. + +## Test Your Knowledge + +1. Why is it recommended to use self-contained deployments for distributing MonoGame games? + + :::question-answer + Self-contained deployments package your game with all necessary .NET dependencies, ensuring players can run the game without installing the .NET runtime. This simplifies distribution, guarantees your game uses the exact runtime version it was developed with, and provides a better player experience despite the larger package size. + ::: + +2. Why should ReadyToRun and Tiered Compilation be disabled when publishing games? + + :::question-answer + ReadyToRun and Tiered Compilation both initially produce lower-quality code that gets optimized during runtime. This dynamic optimization process causes micro-stutters during gameplay as the Just-In-Time compiler triggers to improve code quality. Disabling these features results in slightly longer startup times but provides smoother gameplay without performance hitches. + ::: + +3. What is the purpose of the `Info.plist` file when creating a macOS application bundle? + + :::question-answer + The `Info.plist` file contains essential metadata about the macOS application, including the bundle identifier, application name, version, copyright information, minimum system requirements, and other configuration details. macOS requires this file to properly recognize and display the application in the Finder and Dock, and to associate the correct icon and file types with the application. + ::: + +4. What is the advantage of using a tar.gz archive over zip file when distributing for macOS and Linux? + + :::question-answer + A tar.gz archive preserves Unix file permissions, which is crucial for maintaining the executable permissions set on game files. Without these permissions, users would need to manually set execute permissions before running the game. ZIP files do not reliably preserve these Unix-specific permissions, which could prevent the game from running directly after extraction on macOS and Linux platforms. + ::: + +5. What is the purpose of creating a universal binary for macOS distributions? + + :::question-answer + A universal binary combines executables for multiple CPU architectures (Intel x64 and Apple Silicon arm64) into a single file. This allows the game to run natively on both older Intel-based Macs and newer Apple Silicon Macs without requiring separate distributions. + ::: diff --git a/articles/tutorials/building_2d_games/25_packaging_game/snippets/info.plist b/articles/tutorials/building_2d_games/25_packaging_game/snippets/info.plist new file mode 100644 index 00000000..6938c683 --- /dev/null +++ b/articles/tutorials/building_2d_games/25_packaging_game/snippets/info.plist @@ -0,0 +1,40 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + DungeonSlime + CFBundleIconFile + DungeonSlime + CFBundleIdentifier + com.yourdomain.dungeonslime + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + DungeonSlime + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSignature + FONV + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.games + LSMinimumSystemVersion + 10.15 + NSHumanReadableCopyright + Copyright © 2025 + NSPrincipalClass + NSApplication + LSRequiresNativeExecution + + LSArchitecturePriority + + arm64 + + + \ No newline at end of file diff --git a/articles/tutorials/building_2d_games/26_publish_to_itch/images/basic-information.png b/articles/tutorials/building_2d_games/26_publish_to_itch/images/basic-information.png new file mode 100644 index 00000000..0bed0910 Binary files /dev/null and b/articles/tutorials/building_2d_games/26_publish_to_itch/images/basic-information.png differ diff --git a/articles/tutorials/building_2d_games/26_publish_to_itch/images/community.png b/articles/tutorials/building_2d_games/26_publish_to_itch/images/community.png new file mode 100644 index 00000000..e56e1970 Binary files /dev/null and b/articles/tutorials/building_2d_games/26_publish_to_itch/images/community.png differ diff --git a/articles/tutorials/building_2d_games/26_publish_to_itch/images/cover-image-uploaded.png b/articles/tutorials/building_2d_games/26_publish_to_itch/images/cover-image-uploaded.png new file mode 100644 index 00000000..41a41d75 Binary files /dev/null and b/articles/tutorials/building_2d_games/26_publish_to_itch/images/cover-image-uploaded.png differ diff --git a/articles/tutorials/building_2d_games/26_publish_to_itch/images/create-a-new-project-page.png b/articles/tutorials/building_2d_games/26_publish_to_itch/images/create-a-new-project-page.png new file mode 100644 index 00000000..50023350 Binary files /dev/null and b/articles/tutorials/building_2d_games/26_publish_to_itch/images/create-a-new-project-page.png differ diff --git a/articles/tutorials/building_2d_games/26_publish_to_itch/images/description.png b/articles/tutorials/building_2d_games/26_publish_to_itch/images/description.png new file mode 100644 index 00000000..68045bbc Binary files /dev/null and b/articles/tutorials/building_2d_games/26_publish_to_itch/images/description.png differ diff --git a/articles/tutorials/building_2d_games/26_publish_to_itch/images/download-install-instructions.png b/articles/tutorials/building_2d_games/26_publish_to_itch/images/download-install-instructions.png new file mode 100644 index 00000000..f6f0a8fe Binary files /dev/null and b/articles/tutorials/building_2d_games/26_publish_to_itch/images/download-install-instructions.png differ diff --git a/articles/tutorials/building_2d_games/26_publish_to_itch/images/game-page-popup.png b/articles/tutorials/building_2d_games/26_publish_to_itch/images/game-page-popup.png new file mode 100644 index 00000000..9928d41c Binary files /dev/null and b/articles/tutorials/building_2d_games/26_publish_to_itch/images/game-page-popup.png differ diff --git a/articles/tutorials/building_2d_games/26_publish_to_itch/images/metadata.png b/articles/tutorials/building_2d_games/26_publish_to_itch/images/metadata.png new file mode 100644 index 00000000..77f89732 Binary files /dev/null and b/articles/tutorials/building_2d_games/26_publish_to_itch/images/metadata.png differ diff --git a/articles/tutorials/building_2d_games/26_publish_to_itch/images/pricing.png b/articles/tutorials/building_2d_games/26_publish_to_itch/images/pricing.png new file mode 100644 index 00000000..e8382293 Binary files /dev/null and b/articles/tutorials/building_2d_games/26_publish_to_itch/images/pricing.png differ diff --git a/articles/tutorials/building_2d_games/26_publish_to_itch/images/project-page.png b/articles/tutorials/building_2d_games/26_publish_to_itch/images/project-page.png new file mode 100644 index 00000000..3f51477c Binary files /dev/null and b/articles/tutorials/building_2d_games/26_publish_to_itch/images/project-page.png differ diff --git a/articles/tutorials/building_2d_games/26_publish_to_itch/images/screenshots-added.png b/articles/tutorials/building_2d_games/26_publish_to_itch/images/screenshots-added.png new file mode 100644 index 00000000..3bde52f4 Binary files /dev/null and b/articles/tutorials/building_2d_games/26_publish_to_itch/images/screenshots-added.png differ diff --git a/articles/tutorials/building_2d_games/26_publish_to_itch/images/upload-new-project.png b/articles/tutorials/building_2d_games/26_publish_to_itch/images/upload-new-project.png new file mode 100644 index 00000000..a7f23bb2 Binary files /dev/null and b/articles/tutorials/building_2d_games/26_publish_to_itch/images/upload-new-project.png differ diff --git a/articles/tutorials/building_2d_games/26_publish_to_itch/images/upload.png b/articles/tutorials/building_2d_games/26_publish_to_itch/images/upload.png new file mode 100644 index 00000000..f753bdd7 Binary files /dev/null and b/articles/tutorials/building_2d_games/26_publish_to_itch/images/upload.png differ diff --git a/articles/tutorials/building_2d_games/26_publish_to_itch/images/visibility-access.png b/articles/tutorials/building_2d_games/26_publish_to_itch/images/visibility-access.png new file mode 100644 index 00000000..7b922456 Binary files /dev/null and b/articles/tutorials/building_2d_games/26_publish_to_itch/images/visibility-access.png differ diff --git a/articles/tutorials/building_2d_games/26_publish_to_itch/index.md b/articles/tutorials/building_2d_games/26_publish_to_itch/index.md new file mode 100644 index 00000000..d403e8d4 --- /dev/null +++ b/articles/tutorials/building_2d_games/26_publish_to_itch/index.md @@ -0,0 +1,484 @@ +--- +title: "Chapter 26: Publishing Your Game to itch.io" +description: "Learn how to deploy your MonoGame project to itch.io and configure it for players across different platforms." +--- + +After packaging your game for various platforms in [Chapter 25](../25_packaging_game/index.md), the next step is to make it available for other players. While there are many distribution platforms available ([Steam](https://store.steampowered.com/), [Epic Games store](https://store.epicgames.com/en-US/), [Good Old Games (GOG)](https://www.gog.com/en)), [itch.io](https://itch.io/) has become a popular choice for indie developers due to its simple publishing processes, developer-friendly revenue model, and strong community focus. + +In this chapter, you will: + +- Learn about itch.io and why it is beneficial for indie game developers. +- Create and configure an itch.io developer account with appropriate security measures. +- Create a project page that effectively showcases your game. + +## Understanding itch.io as a Distribution Platform + +Itch.io is an open marketplace for independent digital creators with a focus on indie games. Unlike larger platforms with strict approval processes, itch.io allows any developer to publish their games instantly. This makes it an ideal starting point for new developers looking to build an audience. + +Key benefits of itch.io for developers include: + +1. **Simple Publishing Process**: No approval waiting periods or complex requirements. +2. **Pay-What-You-Want Pricing**: Flexibility to set minimum prices, suggested prices, or make your game completely free. +3. **Cross-Platform Support**: Easy distribution for Windows, macOS, and Linux builds. +4. **Community Focused**: Built-in tools for building a community around you and your games, including devlogs and comments. +5. **Developer-Friendly Revenue Split**: The platform lets developers choose their revenue share (the default 90% to developers, 10% to itch.io). +6. **Analytics and Insights**: Basic analytics to track downloads, views, and purchases. +7. **Game Jams**: Integrated tools for hosting and participating in game development competitions. + +## Setting Up an itch.io Account + +Before you can publish your game, you need to create an itch.io account and set up your developer profile. + +To do this: + +1. Navigate to [itch.io](https://itch.io) and click "Register" in the top-right corner. +2. Fill in the registration form with your desired username, password, and email address. + + > [!TIP] + > Your itch.io username will appear in the URL of your games (e.g. `https://yourusername.itch.io/dungeon-slime`). Choose a username that represents you or your studio professionally and is easy to remember. + > + > The username can be changed later in your account settings if needed. + +3. After registering, you will automatically be logged in. However, for some additional configurations that need to be made, you must validate your email address. Check the email address you entered when you registered for a new email from itch.io to validate the email address. +4. After validating your email address, go to your account settings by clicking your username in the top-right corner, and then choosing "Settings". +5. The settings page opens with your developer profile configuration. Complete your developer profile with: + - A profile picture. + - Links to your social media platforms or portfolio. + - A short biography for your profile page. +6. Next, click "Two factor auth" under the "Basics" section on the left to go through the process of configuring two factor authentication for your account. +7. If you plan to sell your game, you will need to configure your publisher information. To do this, click the "Get Started" section under "Publisher" on the left and follow the steps presented to configure your account to accept payments. + +## Creating a New Project + +To put your game on itch.io, you first need to create a new project. When creating a new project, you will be filling out information about the game to generate a project page that contains all the information about the game including the title, description, screenshots, and download files. + +To create a new project, click the arrow beside your username in the top-right corner of the page, and choose "Upload new project". + +| ![Figure 26-1: Drop-down menu after clicking the arrow beside user name with "Upload new project" highlighted](./images/upload-new-project.png) | +| :---------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 26-1: Drop-down menu after clicking the arrow beside user name with "Upload new project" highlighted** | + +This will open the "Create a new project page", which presents you with a form to to enter information abut the game, upload the game files, and add screenshots. + +| ![Figure 26-2: The "Create a new project" page on itch.io](./images/create-a-new-project-page.png) | +| :------------------------------------------------------------------------------------------------: | +| **Figure 26-2: The "Create a new project" page on itch.io** | + +> [!TIP] +> You can also create a new project by going to your dashboard by clicking the "Dashboard" navigation link at the top of the page, then clicking the "Create new project" button on the dashboard page. + +### Basic Project Information + +The first section of the form contains fields for the basic information about the project. + +1. **Title**: The title of the game. This is how it will be displayed and shown throughout the itch.io website. +2. **Project URL**: The URL of the project. This is the address you will share on social media platforms and other communities to point others to the project page for your game. + + > [!NOTE] + > When you enter the title of your game, the project URL will automatically generate based on the title entered (e.g. `https://yourusername.itch.io/project-title`). The autogenerated URL is usually fine, but you can customize it if you are not happy with it. + +3. **Short description or tagline**: Here you can enter a short description that is shown in embedded link previews for your game when it is shared on different social media platforms. +4. **Kind of project**: The type of project you are uploading. The available options are: + + - **Downloadable**: For projects that require users to download and run files locally. This is the appropriate choice for MonoGame projects since they are distributed as executable packages. + - **HTML**: For web-based games or applications that run directly in the browser, typically using HTML5, JavaScript, and CSS. + - **Flash**: For legacy Flash applications that require the Flash Player plugin. This format is largely obsolete since Adobe discontinued Flash support. + - **Java applet**: For Java-based applications that run in a browser. Like Flash, this technology has been largely phased out of modern web development. + - **Unity <= 5.3**: For older Unity Web Player games that run in the browser. Newer Unity games typically use WebGL, which would fall under the HTML category. + + > [!TIP] + > Since MonoGame games are distributed by packaging them into an archived bundle, you should choose the "Downloadable" option. + +5. **Classification**: What type of project are you uploading. The available options are: + + - **Games**: Interactive software designed primarily for entertainment or educational purposes. + - **Game assets**: Resources used in game development, such as graphics, audio, fonts, or code modules. + - **Game mods**: Modifications that alter or enhance an existing game's content or mechanics. + - **Physical games**: Tabletop, card, or other non-digital games that can be printed or manufactured. + - **Albums & soundtracks**: Musical compositions, including game soundtracks or independent music projects. + - **Tools**: Utility software that assists in game development or other creative processes. + - **Comics**: Sequential art narratives in digital format. + - **Books**: Literary works including novels, guides, or educational texts. + - **Other**: Projects that do not fit into the predefined categories. + +6. **Release Status**: Set the development status of the project. The available options are: + + - **Released**: The project is complete and ready for public distribution, though it may receive updates. + - **In development**: The project is actively being worked on and may be available as an early access version. + - **On hold**: Development has been temporarily paused but is expected to resume in the future. + - **Canceled**: Development has been permanently discontinued. + - **Prototype**: An early experimental version meant for concept testing rather than complete gameplay. + + > [!NOTE] + > You can change the release status of the project at any time after creating the project page. + +The following is an example of the basic project information that would be filled in for the Dungeon Slime game we developed throughout this tutorial series: + +| ![Figure 26-3: The basic project information for the Dungeon Slime game](./images/basic-information.png) | +| :------------------------------------------------------------------------------------------------------: | +| **Figure 26-3: The basic project information for the Dungeon Slime game** | + +### Pricing + +Following the basic information about the project, the next section of the form is for setting the price users would pay to download the game. Itch.io offers three pricing models for a project: + +1. **$0 or donate**: This model makes your game freely available while encouraging optional financial support. When users download your game, they are presented with a donation prompt showing your suggested amount. While they can contribute any amount they choose, they also have the option to download without payment by selecting "No thanks, take me to the download.". +2. **Paid**: This model requires users to purchase your game before downloading. You set a minimum price that all users must pay, though they have the option to contribute more if they wish. +3. **No Payment**: This model provides your game completely free with no payment prompts. When users click to download, they are taken directly to the download page without any suggestion to donate. + +> [!TIP] +> Consider your goals when selecting a pricing model. The **"$0 or donate"** option often provides a good balance for indie developers, removing financial barriers to trying your game while still providing a path for supportive players to contribute. + +| ![Figure 26-4: The pricing options for a new project on itch.io](./images/pricing.png) | +| :------------------------------------------------------------------------------------: | +| **Figure 26-4: The pricing options for a new project on itch.io** | + +### Uploads + +After choosing the pricing options for the project, the next part of the form is for uploading files that users can download for the project. This is where you will add the platform-specific builds you created using the steps in [Chapter 25](../25_packaging_game/index.md). Itch.io supports multiple file uploads, allowing you to provide the appropriate version for each platform you support. + +> [!NOTE] +> Before uploading, ensure the builds are properly packaged into either ZIP (Windows) or tar.gz (macOS/Linux) archives as discussed in [Chapter 25](../25_packaging_game/index.md). + +To upload a file for the project: + +1. Click the "Upload files" button. +2. In the file select dialog, navigate to and select the file you want to upload. + +> [!NOTE] +> For a MonoGame game project, you would upload the archived builds that you created through the steps in [Chapter 25](../25_packaging_game/index.md). + +Once the upload has completed, you will be presented with options to set the following: + +1. **Download Type**: Here you can set the type of file that the user will be downloading. The available options are: + + - **Executable**: The main game application that users will run to play your game. For MonoGame projects, your packaged builds fall into this category. + - **Source code**: The underlying code of your game, useful for open-source projects or educational examples. + - **Soundtrack**: Music files from your game, typically provided in MP3 or other audio formats. + - **Book or Document**: Text content such as manuals, guides, or other written materials. + - **Video**: Video content such as trailers, gameplay footage, or tutorials. + - **Mod**: Add-ons or modifications that enhance or alter your base game. + - **Graphical assets**: Art, textures, models, or other visual elements used in your game. + - **Audio assets**: Sound effects, voice recordings, or other audio components. + - **Documentation or Instructions**: Help files, control references, or other guidance for players. + - **Other**: Any file type that does not fit into the above categories. + + > [!TIP] + > Even if the project you are creating is a game, you can include additional downloads for users such as the soundtrack and an instruction manual. Providing these supplementary materials can enhance the perceived value of your project and demonstrate professionalism and attention to detail. + +2. If the download type is "Executable", you will be presented with checkboxes to choose which operating system it is for. The available options are: + + - **Windows**: For Windows-compatible executable files (.exe). + - **Linux**: For Linux-compatible executable files. + - **macOS**: For macOS-compatible application bundles (.app). + - **Android**: For Android-compatible application packages (.apk). + +For each file that is uploaded, you are also given the following options: + +- **Set a different price for this file**: This allows you to adjust pricing for specific downloads, useful for offering premium content or special editions. +- **Hide this file and prevent it from being downloaded**: This can be used to temporarily remove access to a file without deleting it, which is helpful when updating content or preparing future releases. + +> [!NOTE] +> Itch.io also provides a command-line tool called **butler** that can automate the upload process for your game builds. This is particularly useful for developers who frequently update their games or need to manage multiple platforms. For more information, see the [butler GitHub page](https://github.com/itchio/butler). +> +> MonoGame community member [Jean-David Moisan (Apos)](https://github.com/Apostolique) also has a guide written on automating releases using butler with GitHub Actions. For more information, see the [Automate Release](https://learn-monogame.github.io/how-to/automate-release/) document. + +| ![Figure 26-5: Example of uploaded files for Windows, Linux, and macOS platforms](./images/upload.png) | +| :-----------------------------------------------------------------------------------------------------: | +| **Figure 26-5: Example of uploaded files for Windows, Linux, and macOS platforms** | + +### Description + +The description section is where you can showcase your game's unique appeal and provide potential players with compelling reasons to download it. An effective game description serves multiple purposes: it explains what your game is about, highlights its distinctive features, and gives players an idea of what to expect. + +When writing a description for your game, consider the following strategies to create a compelling presentation: + +- **Start with a hook**: Begin with a concise, attention-grabbing summary that clearly communicates your game's concept and unique selling point. +- **Structure for readability**: Use short paragraphs, bullet points, and clear headings to make your description easy to scan. +- **Highlight key features**: List the most notable gameplay mechanics, visual elements, and player experiences your game offers. +- **Include technical information**: Mention the technologies used (such as MonoGame 😉), supported platforms, and any special hardware requirements or recommendations. +- **Describe the controls**: Briefly explain how players will interact with your game, especially if you support multiple input methods. +- **Add attribution**: Include proper credits for team members, external assets, and tools used in development that require attributions. +- **Consider your audience**: Write in a tone that resonates with your target players. + +Here is an example structure for a game description: + +```text +[Game Name] is a [genre] game where players [main gameplay activity]. [One additional sentence about a unique hook or setting]. + +Features: + +- [Key Feature 1] +- [Key Feature 2] +- [Key Feature 3] +- [Key Feature 4] + +Controls: +- [Control scheme for keyboard/mouse] +- [Control scheme for gamepad, if supported] + +Developed with MonoGame +``` + +> [!TIP] +> Consider reviewing descriptions of popular games in your genre for inspiration, but ensure your description authentically represents your game. False or exaggerated claims will lead to disappointed players and negative reviews. + +| ![Figure 26-6: Example of description a game](./images/description.png) | +| :---------------------------------------------------------------------: | +| **Figure 26-6: Example of description a game** | + +### Metadata + +Itch.io provides several metadata fields that help categorize your game and make it more discoverable when users search the platform. Properly configured metadata increases visibility and helps your game reach its intended audience. The following explores each of these fields: + +1. **Genre**: Select the genre that most accurately represents your game's core gameplay experience. While many games span multiple genres, choose the one that best captures your game's primary appeal. Common genres include: + + - Action (for games focusing on reflexes and coordination) + - Adventure (for exploration and narrative-driven experiences) + - Puzzle (for games centered on problem-solving) + - Role Playing (for character development and progression systems) + - Simulation (for realistic system modeling) + - Strategy (for tactical decision-making games) + +2. **Tags**: Tags are keywords that help users find your game when searching or browsing. Effective tagging significantly impacts your game's discoverability. Consider including: + - Platform identifiers (2d, 3d) + - Visual style (pixel-art, low-poly, realistic) + - Technical elements (monogame, procedural) + - Player configuration (singleplayer, multiplayer) + - Gameplay mechanics (shooter, platformer, roguelike) + + > [!TIP] + > Itch.io recommends using their suggested tags when possible. Review their [tagging guidelines](https://itch.io/docs/creators/quality-guidelines#tags) for best practices. Focus on accuracy rather than quantity—irrelevant tags may attract the wrong audience and lead to disappointed players. + +3. **AI generation disclosure**: This field requires you to indicate whether your game contains AI-generated assets. This transparency helps users make informed decisions about the content they consume. Options include: + - Yes: The project contains AI-generated assets. + - No: The project does not contain any AI-generated assets. + + > [!NOTE] + > Itch.io takes this disclosure seriously. For detailed information about their AI disclosure policy, visit their [generative AI guidelines](https://itch.io/docs/creators/quality-guidelines#ai-disclosure). + +4. **App store links**: If your game is available on other platforms like Steam, Apple App Store, or Google Play, you can provide links to these listings. This helps players find your game on their preferred platform and establishes your game's presence across multiple marketplaces. + +5. **Custom Noun**: By default, itch.io refers to your project as a "game." If your project fits better with another descriptor (like "tool," "experience," or "interactive story"), you can specify a custom noun here. This affects how your project is described throughout the itch.io interface. + +| ![Figure 26-7: Example of metadata configuration for a game project](./images/metadata.png) | +| :-----------------------------------------------------------------------------------------: | +| **Figure 26-7: Example of metadata configuration for a game project** | + +### Download and Install Instructions + +The download and installation instructions section is where you provide clear guidance to users on how to get your game up and running after they download it. Well-written instructions reduce confusion and ensure players can quickly start enjoying your game. + +Since MonoGame projects are typically distributed as archived files (ZIP for Windows, tar.gz for macOS and Linux), it is essential to provide step-by-step instructions for extracting and launching your game on each supported platform. + +- **Be platform-specific**: Separate instructions for each operating system you support. +- **Use numbered steps**: Sequential numbering makes instructions easier to follow. +- **Be explicit**: Specify exact filenames and actions to take. +- **Address common issues**: Mention any special permissions or requirements users might encounter. +- **Use simple language**: Avoid technical jargon when possible, as players may have varying technical expertise. + +Here is an example template for installation instructions for a MonoGame project: + +```text +Once the download has completed: + +For Windows: + 1. Extract the contents of the ZIP archive. + 2. Run the "[YourGameName].exe" executable from the extracted folder. + +For Linux: + 1. Extract the contents of the tar.gz archive. + 2. Run the "[YourGameName]" executable from the extracted folder. + 3. If the game does not open or states it is not executable, you may need to change the file permissions. To do this: + 1. Open a terminal in the extracted directory. + 2. Make the game executable with the command: chmod +x ./[YourGameName] + +For macOS: + 1. Extract the contents of the tar.gz archive. + 2. Run the [YourGameName].app executable from the extracted folder. + 3. If the game does not open or states it is not an executable, you may need to change the file permissions. To do this: + 1. Open a terminal in the extracted directory. + 2. Make the game executable with the command: chmod +x ./[YourGameName].app/Contents/MacOS/[YourGameName] +``` + +> [!TIP] +> Consider testing your installation instructions on a clean system or with someone who has not seen your game before. This can help identify any missing steps or assumptions you might have made. + +| ![Figure 26-8: Example of download and installation instructions for a MonoGame project](./images/download-install-instructions.png) | +| :----------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 26-8: Example of download and installation instructions for a MonoGame project** | + +### Community + +Building a community around your game can significantly enhance its lifespan and success. Itch.io offers several options for enabling player interaction directly on your project page. Choosing the right level of community engagement depends on your game's needs and your capacity to moderate interactions. + +The platform provides three community feature options: + +- **Disabled**: No community interaction features will be available on your project page. This option is appropriate when you prefer to direct community engagement elsewhere (such as Discord or social media), or when you do not have the resources to moderate community content. This is also suitable for projects in very early development stages where you are not yet ready for public feedback. + +- **Comments**: This enables a simple comment section at the bottom of your project page where players can leave feedback, ask questions, or share their experiences. As the developer, you receive notifications when new comments are posted, allowing you to respond promptly. This option provides a good balance between community engagement and moderation requirements, making it ideal for most indie games. + +- **Discussion board**: This creates a full-featured forum dedicated to your project, with support for multiple topics, threaded replies, and content organization. Discussion boards are beneficial for games with complex mechanics, active development cycles, or strong communities. However, they require more active moderation and engagement from developers to remain valuable. + +> [!TIP] +> Regardless of which option you choose, regularly engaging with your community demonstrates that you value player feedback and can build a loyal following. Consider setting aside specific times each week to respond to comments or discussions. + +| ![Figure 26-9: Community feature settings available for game projects](./images/community.png) | +| :--------------------------------------------------------------------------------------------: | +| **Figure 26-9: Community feature settings available for game projects** | + +### Visibility and Access + +The visibility settings control who can access your project page. This feature allows you to prepare your page privately before making it public, or to create restricted access for testers or supporters. Understanding these options helps you manage your game's release strategy effectively. + +Itch.io offers three visibility levels: + +- **Draft**: This setting makes your project page visible only to you and any collaborators you have explicitly given edit access. Draft mode is ideal during the initial setup phase when you are still uploading files, writing descriptions, and configuring your page. It provides a safe environment to experiment with different layouts and content without public exposure. + +- **Restricted**: This option limits access to specific individuals you designate. When you select this option, you can generate and share access keys with testers, reviewers, or early supporters. Restricted access is valuable for beta testing, gathering feedback from a controlled group, or offering early access to supporters before a wider release. + +- **Public**: This makes your project accessible to everyone visiting itch.io. Once public, your game will appear in search results and category listings, making it discoverable by the platform's user base. This is the appropriate setting when your game is ready for distribution. + +> [!NOTE] +> Itch.io intentionally limits your initial options to either "Draft" or "Restricted" mode. This design encourages you to completely prepare your project page before making it public. Only after saving your initial project setup can you then return to change the visibility to "Public." This workflow helps prevent incomplete or unpolished project pages from being publicly visible. + +> [!TIP] +> Consider using a phased release approach: start in Draft mode while building your page, switch to Restricted for beta testing with a select audience, then finally move to Public when you are confident in your game's presentation and stability. + +| ![Figure 26-10: Visibility and access settings for publishing your game](./images/visibility-access.png) | +| :-----------------------------------------------------------------------------------------------------: | +| **Figure 26-10: Visibility and access settings for publishing your game** | + +### Adding Visual Assets + +Visual assets are often the first elements potential players notice about your game and play a crucial role in marketing. According to industry research, games with high-quality visual presentations typically receive more downloads and engagement than those with minimal or poor-quality visuals. Taking time to create compelling visual assets can significantly improve your game's appeal on the itch.io marketplace. + +At minimum, you should prepare the following visual assets for your project: + +1. **Cover Image**: This is your game's primary visual representation, displayed on the game's page, in search results, and in embedded links when shared on social media. Itch.io recommends a size of 630×500 pixels, with a minimum requirement of 315×250 pixels. Your cover image should: + - Clearly represent your game's visual style + - Include your game's title + - Avoid cluttered compositions that become illegible when scaled down + +2. **Screenshots**: Include 3-5 high-quality screenshots that showcase different aspects of your game. Effective screenshots should: + - Demonstrate core gameplay mechanics + - Highlight unique visual elements or features + - Show different environments or levels if applicable + - Include UI elements only when relevant to showcase + - Maintain consistent aspect ratios across all screenshots + +3. **Trailer or Gameplay Video** (optional but recommended): A short video (30-90 seconds) can increase player interest. Videos allow you to demonstrate gameplay flow, audio design, and interactive elements that static images cannot capture. + +> [!TIP] +> When capturing screenshots and video: +> +> - **Windows**: Use the built-in Game Bar (Windows+G), Snipping Tool, or press `Windows+Shift+S` for screenshots. OBS Studio works well for video capture. +> - **macOS**: Press `Command+Shift+4` for area screenshots or `Command+Shift+5` for screen recording. QuickTime Player also offers screen recording functionality. +> - **Linux**: Use tools like GNOME Screenshot, Flameshot, or SimpleScreenRecorder. + +To add a cover image to your project: + +1. Prepare your image at the recommended 630×500 pixel size. +2. At the top-right of the "Create a new project" page, click the "Upload Cover Image" button. +3. Select your cover image file in the file browser dialog. + +| ![Figure 26-11: Example of uploading a cover image for a game project](./images/cover-image-uploaded.png) | +| :-------------------------------------------------------------------------------------------------------: | +| **Figure 26-11: Example of uploading a cover image for a game project** | + +To add screenshots to your project: + +1. Prepare 3-5 screenshots showcasing different aspects of your game. +2. Click the "Add Screenshots" button located below the cover image section. +3. Select your screenshot files in the file browser dialog. +4. Arrange screenshots in order of importance by hovering over then and using the "Move Up" and "Move Down" actions after uploading. + +| ![Figure 26-12: Example of multiple screenshots added to a game project](./images/screenshots-added.png) | +| :------------------------------------------------------------------------------------------------------: | +| **Figure 26-12: Example of multiple screenshots added to a game project** | + +> [!NOTE] +> While itch.io does not strictly require these visual assets, projects with complete visual presentations receive more attention than those without. + +### Saving and Publishing Your Project + +After filling out all the necessary form fields, uploading your game files, and adding visual assets, it is time to save your project and prepare it for publishing. This process involves several steps designed to ensure your project is ready for your intended audience. + +#### Initial Save and Preview + +Once you have completed the project creation form: + +1. Scroll to the bottom of the form and click the "Save & view page" button. +2. This action saves all your current information and generates a preview of your project page. +3. A popup will appear with important information about your project's status and next steps. + + | ![Figure 26-13: Initial information popup after saving a new project](./images/game-page-popup.png) | + | :-------------------------------------------------------------------------------------------------: | + | **Figure 26-13: Initial information popup after saving a new project** | + +4. Click the "Got it" button after reading to close the popup and view your project page preview. + +| ![Figure 26-14: Example of a project page preview after initial save](./images/project-page.png) | +| :----------------------------------------------------------------------------------------------: | +| **Figure 26-14: Example of a project page preview after initial save** | + +#### Visual Customization + +The preview page provides additional customization options beyond the basic project information you have already entered. As the project owner, you will notice an "Edit theme" button at the top of your preview page. This feature allows you to customize the visual presentation of your project page with options including: + +- **Background color**: Change the page's background to match your game's aesthetic. +- **Font selection**: Choose typography that complements your game's style. +- **Layout options**: Modify how elements are arranged on the page. +- **Banner image**: Add a wider image at the top of your page for additional visual impact. +- **Background image**: Set a tiled or full-page background image. + +These visual customizations can significantly enhance your project's presentation and help it stand out among other games on the platform. Experiment with different combinations to find a design that complements your game's identity while maintaining readability and usability. + +#### Publishing Your Project + +When you are satisfied with your project page and ready to make it publicly available: + +1. Click the "Edit game" link in the top navigation bar to return to the project form. +2. Scroll to the bottom of the form to the **Visibility & access** section. +3. Change the setting from "Draft" to "Public." +4. Click the "Save" button to apply the change. + +Your game is now publicly accessible to the itch.io community. It will appear in search results and category listings based on the metadata you provided. + +> [!TIP] +> Before widely promoting your game, consider a soft launch approach: +> +> 1. Share the link with a small group of trusted friends or fellow developers. +> 2. Ask them to test the download and installation process on different devices and operating systems. +> 3. Collect feedback on any unclear instructions or technical issues. +> 4. Make necessary adjustments before promoting your game to a wider audience. +> +> This approach helps identify potential issues you may have missed before presenting it to a larger audience. + +## Dungeon Slime on itch.io + +To see a completed version of a project uploaded to itch.io, you can explore the Dungeon Slime example at [https://shyfoxstudio.itch.io/dungeon-slime](https://shyfoxstudio.itch.io/dungeon-slime). + +This published version demonstrates the concepts we covered in this chapter, showing how they come together in a finished project. + +## Conclusion + +In this chapter, you learned how to publish your MonoGame project on itch.io, one of the most accessible digital distribution platforms for indie game developers. You have gained a comprehensive understanding of the entire publishing process, from creating an account to making your game available to players worldwide. + +> [!NOTE] +> While you might think "why should I publish my demo or small game", we would recommend you do it anyway. The experience you gain from completing a project from start to finish and then publish will gain you more and more experience over time, so that when you finally come to roll out your "big game", you will already know everything involved and be ready for it. +> +> The old saying that "the last 20% takes 80% of the effort" is VERY true when it comes to game development and it pays to be prepared. + +Through this chapter, you have learned how to: + +- Create and configure an itch.io developer account with the appropriate security measures. +- Set up a project page with compelling descriptions and relevant metadata. +- Upload platform-specific builds for different operating systems. +- Configure pricing options that align with your goals. +- Write clear download and installation instructions to ensure a smooth player experience. +- Add visual assets that showcase your game effectively. +- Enable appropriate community features to engage with your audience. +- Customize your project page's visual presentation. + +Publishing a game represents a significant milestone in your journey as a game developer. While itch.io is just one of many distribution platforms available, it provides an excellent starting point for indie developers due to its accessible nature, developer-friendly policies, and supportive community. The skills you have learned in this chapter extend beyond itch.io and establish a foundation for publishing on other platforms. diff --git a/articles/tutorials/building_2d_games/27_conclusion/index.md b/articles/tutorials/building_2d_games/27_conclusion/index.md new file mode 100644 index 00000000..8d4a56f5 --- /dev/null +++ b/articles/tutorials/building_2d_games/27_conclusion/index.md @@ -0,0 +1,105 @@ +--- +title: "Chapter 27: Conclusion and Next Steps" +description: "Review key MonoGame concepts learned, discover community resources, and get practical advice for beginning your own game development projects." +--- + +As our journey together through this tutorial series comes to a close, it is worth taking a moment to reflect on how far you have come. What began as a simple window displaying the iconic "Cornflower Blue" has evolved into a fully functional game with animated sprites, collision detection, sound effects, and user interfaces. This transformation mirrors your own growth as a game developer. + +## What You Have Learned + +Throughout these chapters you have built a complete 2D game from scratch, learning the fundamental concepts and techniques of game development along the way. This journey has equipped you with the knowledge and skills to start creating your own 2D games using MonoGame. Looking back at our journey, we have covered quite a lot of ground: + +### Fundamentals + +Our journey began with essential building blocks of game development with MonoGame. We explored the framework's architecture and capabilities, comparing it to alternatives to understand its unique position in the game development ecosystem. These fundamental concepts provided the foundation upon which we built increasingly complex systems throughout the tutorial series. + +These concepts included: + +- Understanding what MonoGame is and how it compares to other game development frameworks. +- Setting up your development environment across different operating systems. +- Learning the basic structure of a MonoGame project and the game loop. +- Creating a reusable class library for game components. + +### Graphics and Rendering + +With the foundations in place, we ventured into the visual aspects of game development that bring our virtual worlds to life. We explored MonoGame's asset management through the content pipeline, rendering capabilities, and learning techniques to efficiently display graphics while optimizing performance. + +These included: + +- Loading and rendering textures with SpriteBatch. +- Optimizing texture rendering with texture atlases. +- Creating sprites and animated sprites. +- Implementing tilemaps for creating game environments. +- Working with sprite fonts to render text. +- Using shaders to create visual effects. + +### Game Systems + +Beyond graphics, we delved into the interactive systems that transform static visuals into a dynamic gameplay experience. These interconnected components form the backbone of our game, enabling player interaction, providing feedback, and creating a coherent experience across different game states. By implementing these systems, we learned how the various elements of game design work together to create an engaging and responsive player experience. + +These systems included: + +- Handling input from keyboard, mouse, and gamepad. +- Creating an input management system for improved responsiveness. +- Implementing collision detection between game objects. +- Managing audio with sound effects and background music. +- Developing a scene management system for different game states. +- Building a user interface. + +### Production and Distribution + +After creating a fully functioning game, we turned our attention to sharing our creation with the world. The final stages of game development involve preparing your project for players across different platforms and making it available through distribution channels. These steps take your personal project and puts it into a publicly accessible space where others can download, play, and enjoy. + +These stages were: + +- Packaging your game for different platforms. +- Publishing your game on distribution platforms like itch.io. + +## Community and Continued Learning + +The MonoGame community represents one of the most valuable resources available to you as you continue your development journey. Unlike closed commercial engines, MonoGame thrives through the collective efforts of developers who share knowledge, tools, and solutions. + +The official [MonoGame Discord server](https://discord.gg/MonoGame) offers a space where developers of all skill levels exchange ideas and troubleshoot challenges together. Here, a question about optimization techniques might spark a conversation about architecture patterns, leading to insights you might never have discovered on your own. + +For structure learning, several community members have created comprehensive educational resources: + +- [RB Whitaker's](http://rbwhitaker.wikidot.com/MonoGame-getting-started-tutorials) provides in-depth coverage of MonoGame fundamentals and advanced topics. +- [Learn MonoGame](https://learn-MonoGame.github.io/) by community member [Jean-David Moisan (Apos)](https://github.com/Apostolique) provides a collection of focused tutorials on specific MonoGame topics. +- [Dark Genesis Blog](https://darkgenesis.zenithmoon.com/tag.html?tag=MonoGame) by foundation member [Simon Jackson](https://github.com/SimonDarksideJ) offers a range of articles on MonoGame development. +- [GameDevQuickie](https://www.youtube.com/@GameDevQuickie/) offers video tutorials focused on practical game development techniques with MonoGame. +- [Darkside of MonoGame video series](https://www.youtube.com/@DarksideofMonoGame) provides comprehensive tutorials on getting started with MonoGame across different platforms and scenarios. + +The [MonoGame Samples](https://github.com/MonoGame/MonoGame.Samples) repository offers practical examples of features implemented in working code. Similarly, the archived [XNA Game Studio educational resources](https://github.com/SimonDarksideJ/XNAGameStudio) maintained by Simon Jackson provide a wealth of examples that remain relevant despite XNA's official discontinuation. + +> [!TIP] +> **REMEMBER**, if you see content written for XNA, then it is more than likely it will still work for MonoGame due to MonoGame's commitment for backwards compatibility. There maybe a few bumps, minor changes (especially with earlier versions of XNA, like XNA 2) or differences with shaders (the biggest pain). But remember the community is here to help, so just ASK!. + +## Your First Original Project + +As you contemplate your next project, consider staring with a focused concept that builds on what you have learned while introducing one or two new challenges. This balanced approach allows you to reinforce existing skills while gradually expanding your capabilities. + +Perhaps you might create a puzzle game where the core mechanics revolve around tile manipulation, or a side-scrolling adventure that expands on the collision and movement systems you have learned. Whatever you choose, the process will affirm and deepen your understanding in ways that tutorials alone cannot do. In other words, get your hands dirty. + +Remember that your first project does not need to be a commercially successful or technically groundbreaking. Its primary value lies in the creative freedom it offers and the practical experience it provides. Each bug you fix and feature you implement strengthens your developer's intuition; the hard-to-define sense of how games work beneath their surface. + +When you inevitably encounter obstacles, approach them with patience and curiosity. The solutions you discover will not just resolve immediate problems; they will become part of your growing toolkit for future projects. Every challenging moment represents an opportunity to become a more resourceful and knowledgeable developer. + +## The Art of Finishing + +Perhaps the most valuable skill you have practiced throughout this tutorial series is the art of finishing. Game development history is littered with ambitious projects that grew beyond their creators' capacity to complete them. By following this series to its conclusion, you have demonstrated the discipline and persistence for bringing games to life. + +When working on longer projects, break development into milestones with clear, achievable goals. Celebrate these incremental victories to maintain momentum through the inevitable challenges of extended development. Remember that a simple, finished game will always provide more value, both to players and to your grown as a developer, than an ambitious project that remains perpetually incomplete. + +## Final Note From Author + +The knowledge you have gained throughout this tutorial series represents not an endpoint but a beginning. You now possess the fundamental skills to bring your game ideas to life, the architectural understanding to build systems that grow with your ambitions, and the practical experience of completing a functional game. + +The MonoGame framework offers a rare combination of freedom and structure; providing the essential tools without limiting your creative expression. As you continue exploring its capabilities, you join a lineage of independent developers who have used these very same tools to create experiences that resonate with players around the world. + +Your journey from here will be uniquely yours, shaped by the games you want to make and the challenges you choose to overcome. Whatever path you follow, approach it with the same curiosity, persistence, and analytical thinking that brought you through this tutorial series. The road may not always be easy, but it leads to one of the most rewarding creative disciplines available today - the art and science of making games. + +Instead of ending this like all other chapters with a section to "test your knowledge", I will just leave you with one simple question: + +### What will you create next? + +\- [Christopher Whitley (Aristurtle)](https://github.com/AristurtleDev) diff --git a/articles/tutorials/building_2d_games/images/dungeon-slime-game.png b/articles/tutorials/building_2d_games/images/dungeon-slime-game.png new file mode 100644 index 00000000..624b2687 Binary files /dev/null and b/articles/tutorials/building_2d_games/images/dungeon-slime-game.png differ diff --git a/articles/tutorials/building_2d_games/images/dungeon-slime-title.png b/articles/tutorials/building_2d_games/images/dungeon-slime-title.png new file mode 100644 index 00000000..8002ea33 Binary files /dev/null and b/articles/tutorials/building_2d_games/images/dungeon-slime-title.png differ diff --git a/articles/tutorials/building_2d_games/index.md b/articles/tutorials/building_2d_games/index.md new file mode 100644 index 00000000..12453b1c --- /dev/null +++ b/articles/tutorials/building_2d_games/index.md @@ -0,0 +1,121 @@ +--- +title: Building 2D Games with MonoGame +description: A beginner tutorial for building 2D games with MonoGame. +--- + +This tutorial covers game development concepts using the MonoGame framework as our tool. Throughout each chapter, we will explore game development with MonoGame, introducing new concepts to build upon previous ones as we go. We will create a Snake game, which will serve as the vehicle to apply the concepts learned throughout the tutorial. The goal of this tutorial is to give you a solid foundation in 2D game development with MonoGame and provide you with reusable modules that you can leverage to jump start future projects. + +## What We Will Build + +Throughout this tutorial series, we will build a complete game called **Dungeon Slime**; a snake-like game with a dungeon theme. By the end of this series, you will have created a polished game with: + +- Smooth animated sprites +- Responsive controls across keyboard and gamepad +- Sound effects and background music +- UI menus with customized styling +- A complete game loop with scoring and game over states + +You can see the finished game here: [Dungeon Slime on itch.io](https://shyfoxstudio.itch.io/dungeon-slime) + +| ![Figure 0-1: The title screen of Dungeon Slime](./images/dungeon-slime-title.png) | ![Figure 0-2: The gameplay screen of Dungeon Slime showing the player controlling a chain of slime segments (in blue) while navigating through the dungeon to collect bats for points.](./images/dungeon-slime-game.png) | +| :--------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| **Figure 0-1: The title screen of Dungeon Slime** | **Figure 0-2: Gameplay of Dungeon Slime showing the player controlling a chain of slime segments (in blue) while navigating through the dungeon to collect bats for points.** | + +## Who This Is For + +This documentation is meant to be an introduction to game development and MonoGame. Readers should have a foundational understanding of C# and be comfortable with concepts such as classes and objects. + +> [!NOTE] +> If you are just getting started with C# for the first time, I would recommend following the official [Learn C#](https://dotnet.microsoft.com/en-us/learn/csharp) tutorials provided by Microsoft. These free tutorials will teach you programming concepts as well as the C# language. Once you feel you have a good foundation, come back and continue here. + +## How This Documentation Is Organized + +This documentation will introduce game development concepts using the MonoGame framework while walking the reader through the development of a Snake clone. The documentation is organized such that each chapter should be read sequentially, with each introducing new concepts and building off of the previous chapters. + +| Chapter | Summary | Source Files | +| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| [Chapter 01: What Is MonoGame](01_what_is_monogame/index.md) | Learn about the history of MonoGame and explore the features it provides to developers when creating games. | [01-What-Is-MonoGame](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/01-What-Is-MonoGame/) | +| [Chapter 02: Getting Started](02_getting_started/index.md) | Setup your development environment for .NET development and MonoGame using Visual Studio Code as your IDE. | [02-Getting-Started](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/02-Getting-Started/) | +| [Chapter 03: The Game1 File](03_the_game1_file/index.md) | Explore the contents of the Game1 file generated when creating a new MonoGame project. | [03-The-Game1-File](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/03-The-Game1-File/) | +| [Chapter 04: Creating a Class Library](04_creating_a_class_library/index.md) | Learn how to create and structure a reusable MonoGame class library to organize game components and share code between projects. | [04-Creating-A-Class-Library](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/04-Creating-A-Class-Library/) | +| [Chapter 05: Content Pipeline](05_content_pipeline/index.md) | Learn the advantages of using the **Content Pipeline** to load assets and go through the processes of loading your first asset | [05-Content-Pipeline](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/05-Content-Pipeline/) | +| [Chapter 06: Working with Textures](06_working_with_textures/index.md) | Learn how to load and render textures using the MonoGame content pipeline and [**SpriteBatch**](xref:Microsoft.Xna.Framework.Graphics.SpriteBatch). | [06-Working-With-Textures](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/06-Working-With-Textures/) | +| [Chapter 07: Optimizing Texture Rendering](07_optimizing_texture_rendering/index.md) | Explore optimization techniques when rendering textures using a texture atlas. | [07-Optimize-Texture-Rendering](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/07-Optmize-Texture-Rendering/) | +| [Chapter 08: The Sprite Class](08_the_sprite_class/index.md) | Explore creating a reusable Sprite class to efficiently manage sprites and their rendering properties, including position, rotation, scale, and more. | [08-The-Sprite-Class](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/08-The-Sprite-Class/) | +| [Chapter 09: The AnimatedSprite Class](09_the_animatedsprite_class/index.md) | Create an AnimatedSprite class that builds upon our Sprite class to support frame-based animations. | [09-The-AnimatedSprite-Class](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/09-The-AnimatedSprite-Class/) | +| [Chapter 10: Handling Input](10_handling_input/index.md) | Learn how to handle keyboard, mouse, and gamepad input in MonoGame. | [10-Handling-Input](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/10-Handling-Input/) | +| [Chapter 11: Input Management](11_input_management/index.md) | Learn how to create an input management system to handle keyboard, mouse, and gamepad input, including state tracking between frames and creating a reusable framework for handling player input. | [11-Input-Management](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/11-Input-Management/) | +| [Chapter 12: Collision Detection](12_collision_detection/index.md) | Learn how to implement collision detection between game objects and handle collision responses like blocking, triggering events, and bouncing. | [12-Collision-Detection](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/12-Collision-Detection/) | +| [Chapter 14: Sound Effects and Music](14_soundeffects_and_music/index.md) | Learn how to load and play sound effects and background music in MonoGame, including managing audio volume, looping, and handling multiple simultaneous sound effects. | [14-SoundEffects-And-Music](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/14-SoundEffects-And-Music/) | +| [Chapter 15: Audio Controller](15_audio_controller/index.md) | Learn how to create a reusable audio controller class to manage sound effects and music, including volume control, muting/unmuting, and proper resource cleanup. | [15-Audio-Controller](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/15-Audio-Controller/) | +| [Chapter 16: Working with SpriteFonts](16_working_with_spritefonts/index.md) | Learn how to create and use SpriteFonts to render text in your MonoGame project, including loading custom fonts and controlling text appearance. | [16-Working-With-SpriteFonts](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/16-Working-With-SpriteFonts/) | +| [Chapter 17: Scenes](17_scenes/index.md) | Learn how to implement scene management to handle different game screens like menus, gameplay, and transitions between scenes. | [17-Scenes](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/17-Scenes/) | +| [Chapter 18: Texture Sampling](18_texture_sampling/index.md) | Learn how to use texture sampling states and add a scrolling background effect to the game. | [18-Texture-Wrapping](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/18-Texture-Wrapping/) | +| [Chapter 19: User Interface Fundamentals](19_user_interface_fundamentals/index.md) | Learn the core principles of game user interface design. | [19-User-Interface-Fundamentals](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/19-User-Interface-Fundamentals/) | +| [Chapter 20: Implementing UI with Gum](20_implementing_ui_with_gum/index.md) | Learn how to integrate and use the Gum UI framework to create functional menus, buttons, and sliders for your MonoGame projects. | [20-Implementing-UI-With-Gum](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/20-Implementing-UI-With-Gum/) | +| [Chapter 21: Customizing Gum UI](21_customizing_gum_ui/index.md) | Learn how to create custom UI components with animations and visual styling in Gum. | [21-Customizing-Gum-UI](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/21-Customizing-Gum-UI/) | +| [Chapter 22: Snake Game Mechanics](22_snake_game_mechanics/index.md) | Learn how to implement classic snake-like game mechanics and organize game objects into reusable components. | [22-Snake-Game-Mechanics](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/22-Snake-Game-Mechanics/) | +| [Chapter 23: Completing the Game](23_completing_the_game/index.md) | Finalize game mechanics by updating our current demo into a snake-like inspired game. | [23-Completing-The-Game](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/23-Completing-The-Game/) | +| [Chapter 24: Shaders](24_shaders/index.md) | Learn how to create custom visual effects using shaders in MonoGame. | [24-Completing-The-Game](https://github.com/shyfox-studio/learn-monogame-2d/tree/main/src/24-Shaders/) | +| [Chapter 25: Packaging Your Game for Distribution](25_packaging_game/index.md) | Learn how to package your game for distribution across Windows, macOS, and Linux platforms. | | +| [Chapter 26: Publishing Your Game to itch.io](26_publish_to_itch/index.md) | Learn how to deploy your MonoGame project to itch.io and configure it for players across different platforms. | | +| [Chapter 27: Conclusion and Next Steps](27_conclusion/index.md) | Review key MonoGame concepts learned, discover community resources, and get practical advice for beginning your own game development projects. | | + +## Conventions Used in This Documentation + +The following conventions are used in this documentation. + +### Italics + +*Italics* are used for emphasis and technical terms. + +### Highlights + +`Highlights` are used for paths such as file paths, including filenames, extensions and other critical information in the application of steps in a tutorial. These are similar to Inline code blocks as they stand out more in Markdown and require emphasis. + +### Inline Code Blocks + +`Inline code` blocks are used for methods, functions, and variable names when they are discussed within the paragraphs of text. For example, when referring to a method such as `Foo` in a sentence,it will appear with this formatting. + +### Code Blocks + +```cs +// Example Code Block +public void Foo() { } +``` + +Code blocks are used to show code examples with syntax highlight. These appear as separated blocks rather than inline with text, allowing for multi-line examples and better readability of complete code snippets. + +## MonoGame + +If you ever have questions about MonoGame or would like to talk with other developers to share ideas or just hang out with us, you can find us in the various MonoGame communities below: + +- [Discord](https://discord.gg/monogame) +- [GitHub Discussions Forum](https://github.com/MonoGame/MonoGame/discussions) +- [Community Forums (deprecated)](https://community.monogame.net/) +- [Reddit](https://www.reddit.com/r/monogame/) +- [Facebook](https://www.facebook.com/monogamecommunity) + +## Note From Author + +> I have been using MonoGame for many years (since 2017), it was a time in my game development journey where I was looking for something that I had more control over. I did not want to spend the time to write a full game engine, but I also wanted to have more control than what the current engines at the time (i.e. Unity) offered. At that time, there was a vast amount of resources available for getting started, but none of them felt like they fit a good beginner series. Even now, the resources available still seem this way. They either require the reader to have a great understanding of game development and programming, or they assume the reader has none and instead focuses on teaching programming more than teaching MonoGame. Even still, some relied too heavily on third party libraries, while others were simply very bare bones, asking the reader to just copy and paste code without explaining the *what*/*why* of it all. +> +> Since then, I have written various small self contained tutorials on different topics for MonoGame to try and give back to the community for those getting started. I also participate regularly in the community discussion channels, answering questions and offering technical advice, so I am very familiar with the topics and pain points that users get hung up on when first starting out. +> +> With this documentation, I hope to take the lessons I have learned and provide a tutorial series that I wish was available when I first started, and to present using MonoGame in a straight forward way, introducing concepts and building off of them as we go along in a way that makes sense and is easy to follow. +> +> \- Christopher Whitley ([Aristurtle](https://github.com/AristurtleDev)) + +### Acknowledgements + +This documentation would not have been possible without the support and contributions from numerous individuals within the MonoGame community. + +First and foremost, I want to express my gratitude to the [MonoGame Foundation](https://monogame.net/about/), its maintainers, and the countless others contributors, who have preserved and evolved this framework, ensuring developers like myself have the tools needed to bring our creative visions to life. Their dedication to keeping the spirit of XNA alive has been invaluable to indie game developers worldwide. + +I am particularly grateful to the members of the [MonoGame Discord community](https://discord.gg/monogame) who reviewed early drafts of this content, providing feedback that helped shape these chapters into more a accessible and comprehensive learning resource. + +To the many developers of games, such as [Celeste](https://store.steampowered.com/app/504230/Celeste/), thank you for demonstrating what is possible with MonoGame and inspiring newcomers to explore this framework. + +Finally, I would like to thank all the creators and contributors to open-source libraries and tools for MonoGame, including the the creators of [Gum](https://docs.flatredball.com/gum/code/monogame), [Nez](https://github.com/prime31/Nez), and [MonoGame.Extended](https://github.com/craftworkgames/MonoGame.Extended) and the many other libraries that have helped make game development in MonoGame more accessible. + +This documentation stands on the shoulders of a vibrant community that continues to share knowledge, and it is my hope that these tutorials will contribute meaningfully to that ongoing exchange. diff --git a/articles/tutorials.md b/articles/tutorials/index.md similarity index 100% rename from articles/tutorials.md rename to articles/tutorials/index.md diff --git a/docfx.json b/docfx.json index 7168e601..bbeebe7b 100644 --- a/docfx.json +++ b/docfx.json @@ -50,7 +50,9 @@ { "files": [ "**/images/**", + "**/videos/**", "**/files/**", + "**/snippets/**", "CNAME" ] } @@ -72,6 +74,14 @@ "modern", "templates/monogame" ], + "markdownEngineProperties": { + "markdigExtensions": [ + "Abbreviations", + "Figures", + "CustomContainers", + "attributes" + ] + }, "postProcessors": [], "keepFileLink": false, "disableGitFeatures": false diff --git a/templates/monogame/layout/_master.tmpl b/templates/monogame/layout/_master.tmpl index f9686160..8d747c68 100644 --- a/templates/monogame/layout/_master.tmpl +++ b/templates/monogame/layout/_master.tmpl @@ -78,7 +78,8 @@ {{/_enableSearch}} {{>partials/footer}} - + {{/redirect_url}} diff --git a/templates/monogame/public/main.css b/templates/monogame/public/main.css index 580919e0..674e5d69 100644 --- a/templates/monogame/public/main.css +++ b/templates/monogame/public/main.css @@ -48,6 +48,17 @@ body[data-disable-toc="true" i] .toc-offcanvas { visibility: hidden; } +th:has(a img[alt^="Figure"]), +td:has(a img[alt^="Figure"]) { + width: 50%; +} + +th video, +td video { + width: 100%; + height: 100% +} + /******************************************************************************* *** Section: Bootstrap Overrides @@ -230,3 +241,71 @@ p img { margin-top: 1.5em; margin-bottom: 1.5em; } + +/* Resolves issue where xref links in table columns will break and wrap text in +the middle of the word */ +td > .xref { + word-break: normal; +} + +/******************************************************************************* +*** Section: Question and Answer Sections +*** Styling for the questions and answers sections in tutorials +*******************************************************************************/ +.question-answer { + position: relative; + margin: 1rem 0 2rem 0; + padding: 0.75rem; + border: 2px solid var(--mg-orange-primary); + border-radius: 4px; + background-color: var(--bs-body-bg); + cursor: pointer; +} + +/* Initial blacked-out state */ +.question-answer:not(.revealed) { + color: transparent; + background-color: var(--bs-body-bg); + user-select: none; +} + +/* Show "Click to reveal" text when not revealed */ +.question-answer:not(.revealed)::before { + content: "Click to reveal answer"; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--bs-body-color); + font-weight: bold; + pointer-events: none; +} + +/* Reveal on hover style (optional) */ +.question-answer:not(.revealed):hover { + background-color: var(--bs-secondary-bg); +} + +/* Make it accessible */ +.question-answer:focus { + outline: 2px solid var(--mg-orange-secondary); +} + +/* Hide all child elements when not revealed */ +.question-answer:not(.revealed) * { + opacity: 0; +} + +/* When revealed, show content normally */ +.question-answer.revealed { + color: var(--bs-body-color); + background-color: var(--bs-body-bg); +} + +.question-answer.revealed * { + opacity: 1; +} \ No newline at end of file diff --git a/templates/monogame/public/questionAnswer.js b/templates/monogame/public/questionAnswer.js new file mode 100644 index 00000000..c442b83a --- /dev/null +++ b/templates/monogame/public/questionAnswer.js @@ -0,0 +1,28 @@ +document.addEventListener('DOMContentLoaded', function () { + // Find all question-answer containers + const containers = document.querySelectorAll('.question-answer'); + + containers.forEach(function (container) { + // Make the container focusable + container.setAttribute('tabindex', '0'); + + // Add click event to reveal content + container.addEventListener('click', function () { + this.classList.add('revealed'); + }); + + // Add keyboard accessibility + container.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.classList.add('revealed'); + } + }); + + // Optional: Allow re-hiding by right-click + container.addEventListener('contextmenu', function (e) { + e.preventDefault(); + this.classList.remove('revealed'); + }); + }); +}); \ No newline at end of file