Skip to content

Commit a6acacf

Browse files
committed
update hot reload for auto-start, net9, and support dotnet-run
1 parent 81306e6 commit a6acacf

File tree

8 files changed

+133
-8
lines changed

8 files changed

+133
-8
lines changed
52.8 KB
Loading
57.3 KB
Loading

articles/tutorials/advanced/2d_shaders/02_hot_reload/index.md

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ The following command uses the existing target provided by the `MonoGame.Content
7878
Now, when you change a _`.fx`_ file, all of the content files are rebuilt into `.xnb` files.
7979

8080
> [!note]
81-
> When you run `dotnet watch`, that is actually short hand for `dotnet watch run`. The `run` command _runs_ your game, but the `build` only _builds_ your program. Going forward, the `dotnet watch build` commands will not start your game, just build the content. To learn more, read the official documentation for [`dotnet watch`](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch)
81+
> When you run `dotnet watch`, that is actually short hand for `dotnet watch run`. The `run` command _runs_ your game, but the `build` only _builds_ your program. Going forward, the `dotnet watch build` commands will not start your game, they will just build the content. To learn more, read the official documentation for [`dotnet watch`](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch).
8282
8383
However, the `.xnb` files are still not being copied from the `Content/bin` folder to _DungeonSlime_'s runtime folder, the `.xnb` files are only copied during the full MSBuild of the game. The `IncludeContent` target on its own does not have all the context it needs to know how to copy the files in the final game project. To solve this, we need to introduce a new `<Target>` that copies the final `.xnb` files into _DungeonSlime_'s runtime folder.
8484

@@ -119,6 +119,15 @@ And now from the terminal, run the following `dotnet build` command:
119119

120120
[!code-sh[](./snippets/snippet-2-12.sh)]
121121

122+
> [!CAUTION]
123+
> What does `--tl:off` do?
124+
>
125+
> This tutorial series assumes you are using `net8.0`, but theoretically there is nothing stopping you from using later version of `dotnet`. However, in `net9.0`, a [breaking change](https://learn.microsoft.com/en-us/dotnet/core/compatibility/sdk/9.0/terminal-logger) was made to the `dotnet build`'s log output. There is special code that tries to optimize the log output from `dotnet build` so it does not feel overwhelming to look at. This system is called the _terminal logger_, and sadly it hides the underlying log output from `dotnet watch`. It was _opt-in_ for `net8.0`, but in `net9.0`, it is is _enabled_ by default.
126+
>
127+
> **If you are using `net9.0` or above, you _must_ include this option.**
128+
>
129+
> `--tl:off` disables the terminal logger so that the `dotnet watch` log output does not get intercepted by the terminal logger.
130+
122131
We now have a way to dynamically recompile shaders on file changes and copy the `.xnb` files into the game folder! There are a few final adjustments to make to the configuration.
123132

124133
### DotNet Watch, but smarter
@@ -220,12 +229,25 @@ It is time to extend the `ContentManagerExtensions` extension method that the ga
220229

221230
[!code-csharp[](./snippets/snippet-2-23.cs)]
222231

223-
2. At the top of the `Update()` method in the `GameScene` class, add the following line to opt into reloading the `_grayscaleEffect` asset:
232+
2. There are two common ways to run your game as you develop,
233+
- running the game from a terminal by typing `dotnet run`,
234+
- running the game from an IDE.
235+
236+
When you use `dotnet run`, dotnet itself set the [_working directory_](https://learn.microsoft.com/en-us/dotnet/api/system.io.directory.getcurrentdirectory?view=net-9.0) of the program to the folder that contains your `DungeonSlime.csproj` file. However, many IDEs will set the working directory to be within the `/bin` folder of your project, next to the built `DungeonSlime.dll` file.
237+
238+
The working directory is important, because the `ContentManagerExtensions.cs` class we wrote uses the `manager.RootDirectory` to reassemble content `.xnb` file paths. The `manager.RootDirectory` is derived from the working directory, so if the working directory changes based on how we start the game, our `ContentManagerExtensions.cs` code will produce different `.xnb` paths.
239+
240+
The actual `.xnb` files are in the `/bin` subfolder, so at the moment, running the game from the terminal will not work unless you _manually_ specify the working directory. To solve, this we can force the working directory by adding the [`<RunWorkingDirectory>`](https://learn.microsoft.com/en-us/dotnet/core/project-sdk/msbuild-props#runworkingdirectory) property to the `DungeonSlime.csproj` file:
241+
242+
[!code-xml[](./snippets/snippet-2-36.xml?highlight=5)]
243+
244+
3. At the top of the `Update()` method in the `GameScene` class, add the following line to opt into reloading the `_grayscaleEffect` asset:
224245

225246
[!code-csharp[](./snippets/snippet-2-24.cs?highlight=3-4)]
226247

227-
3. Now, when the `grayscaleEffect.fx` file is modified, the `dotnet watch` system will compile it to an `.xnb` file, copy it to the game's runtime folder, and then in the `Update()` loop, the `TryRefresh()` method will load the new effect and the new shader code will be running live in the game.
228-
Try it out by adding this temporary line right before the `return` statement in the `grayscaleEffect.fx` file, make sure the `dotnet build -t:WatchContent` is running in the terminal and [start the game in debug](/articles/tutorials/building_2d_games/02_getting_started/index.html?tabs=windows#creating-your-first-monogame-application) as normal:
248+
4. Now, when the `grayscaleEffect.fx` file is modified, the `dotnet watch` system will compile it to an `.xnb` file, copy it to the game's runtime folder, and then in the `Update()` loop, the `TryRefresh()` method will load the new effect and the new shader code will be running live in the game.
249+
250+
Try it out by adding this temporary line right before the `return` statement in the `grayscaleEffect.fx` file, make sure the `dotnet build -t:WatchContent` is running in the terminal and [start the game in debug](/articles/tutorials/building_2d_games/02_getting_started/index.html?tabs=windows#creating-your-first-monogame-application) as normal:
229251

230252
[!code-hlsl[](./snippets/snippet-2-25.hlsl?highlight=18-19)]
231253

@@ -297,6 +319,37 @@ It is annoying to have use the `ContentManager` directly to call `TryRefresh` in
297319

298320
[!code-csharp[](./snippets/snippet-2-35.cs?highlight=4)]
299321

322+
### Auto start the watcher
323+
324+
The hot reload system is working, but it has a serious weakness. You need to _remember_ to run the following command before starting your game:
325+
326+
[!code-sh[](./snippets/snippet-2-12.sh)]
327+
328+
After you run that command in a terminal, you need to start your game normally. If you _only_ started the game, but never started the watcher, then your shaders would never be hot reloadable. This kind of error is dangerous because it can undermine trust in the hot reload system itself. It would be better if the watcher was started automatically when the game is run, so that you only need to do one thing, _run the game_.
329+
330+
In the `ContentManagerExtensions.cs` file, add this function to the class:
331+
332+
[!code-cs[](./snippets/snippet-2-37.cs)]
333+
334+
You will need to add the following `using` statement to the top of the file:
335+
336+
```csharp
337+
using System.Reflection;
338+
```
339+
340+
Then, call the new function from the DungeonSlime's `Program.cs` file before starting the game:
341+
342+
[!code-cs[](./snippets/snippet-2-37.cs?highlight=1)]
343+
344+
Now, you do not need to start the watcher manually. Instead, you can simply the start the game normally, and the watcher process should appear in the background.
345+
346+
> [!tip]
347+
> If you are running the game via the terminal, and you do _not_ want to start the background content watcher, add the `--no-reload` command line option.
348+
349+
| ![Figure 2-2: The content watcher will appear as a separate window](./images/background-watcher.png) | ![Figure 2-3: The _DungeonSlime_ game appears as normal](./images/game.png) |
350+
| :--------------------------------------------------------------------------------------------------: | --------------------------------------------------------------------------: |
351+
| **Figure 2-2: The content watcher will appear as a separate window** | **Figure 2-3: The _DungeonSlime_ game appears as normal** |
352+
300353
## Conclusion
301354

302355
And with that, we have a powerful hot-reload system in place! In this chapter, you accomplished the following:
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
dotnet build -t:WatchContent
1+
dotnet build -t:WatchContent --tl:off
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<!-- ... -->
3+
4+
<PropertyGroup>
5+
<RunWorkingDirectory>bin/$(Configuration)/$(TargetFramework)</RunWorkingDirectory>
6+
</PropertyGroup>
7+
8+
<!-- ... -->
9+
</Project>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
[Conditional("DEBUG")]
2+
public static void StartContentWatcherTask()
3+
{
4+
var args = Environment.GetCommandLineArgs();
5+
foreach (var arg in args)
6+
{
7+
// if the application was started with the --no-reload option, then do not start the watcher.
8+
if (arg == "--no-reload") return;
9+
}
10+
11+
// identify the project directory
12+
var projectFile = Assembly.GetEntryAssembly().GetName().Name + ".csproj";
13+
var current = Directory.GetCurrentDirectory();
14+
string projectDirectory = null;
15+
16+
while (current != null && projectDirectory == null)
17+
{
18+
if (File.Exists(Path.Combine(current, projectFile)))
19+
{
20+
// the valid project csproj exists in the directory
21+
projectDirectory = current;
22+
}
23+
else
24+
{
25+
// try looking in the parent directory.
26+
// When there is no parent directory, the variable becomes 'null'
27+
current = Path.GetDirectoryName(current);
28+
}
29+
}
30+
31+
// if no valid project was identified, then it is impossible to start the watcher
32+
if (string.IsNullOrEmpty(projectDirectory)) return;
33+
34+
// start the watcher process
35+
var process = Process.Start(new ProcessStartInfo
36+
{
37+
FileName = "dotnet",
38+
Arguments = "build -t:WatchContent --tl:off",
39+
WorkingDirectory = projectDirectory,
40+
WindowStyle = ProcessWindowStyle.Normal,
41+
UseShellExecute = false,
42+
CreateNoWindow = false
43+
});
44+
45+
// when this program exits, make sure to emit a kill signal to the watcher process
46+
AppDomain.CurrentDomain.ProcessExit += (_, __) =>
47+
{
48+
try
49+
{
50+
if (!process.HasExited)
51+
{
52+
process.Kill(entireProcessTree: true);
53+
}
54+
}
55+
catch
56+
{
57+
/* ignore */
58+
}
59+
};
60+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
MonoGameLibrary.Content.ContentManagerExtensions.StartContentWatcherTask();
2+
using var game = new DungeonSlime.Game1();
3+
game.Run();

articles/tutorials/advanced/2d_shaders/03_the_material_class/index.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ In this chapter, we will create a small wrapper class, called the `Material`, th
1313
If you are following along with code, here is the code from the end of the [previous chapter](https://github.com/MonoGame/MonoGame.Samples/tree/3.8.4/Tutorials/2dShaders/src/02-Hot-Reload-System/).
1414

1515
> [!NOTE]
16-
> The tutorial assumes you have the `watch` process running in the terminal in VSCode, if it is not, start the process using:
16+
> The tutorial assumes you have the `watch` process running automatically as you start the _DungeonSlime_ game. Otherwise, make sure to start it manually in the terminal in VSCode:
1717
>
18-
> ```dotnet build -t:WatchContent```
18+
> ```dotnet build -t:WatchContent --tl:off```
1919
2020
## The Material Class
2121

@@ -107,7 +107,7 @@ The `_indexLookup` is a `Dictionary<string, int>` containing a mapping of proper
107107

108108
[!code-csharp[](./snippets/snippet-3-11.cs)]
109109

110-
4. ANd we MUST not forget to invoke this method during the constructor of the `Material`:
110+
4. And we must not forget to invoke this method during the constructor of the `Material`:
111111

112112
[!code-csharp[](./snippets/snippet-3-12.cs?highlight=4)]
113113

0 commit comments

Comments
 (0)