Blazor Gamedev – part 11: improved assets loading
Hi All! Welcome back to part 11 of our Blazor 2d Gamedev series. Today we’re going to refactor and improve the code responsible for loading assets.
Last time we talked about Scene Graphs and how they can help us managing our Game Entities. We used the classic solar system example, with every planet represented by its own asset, loaded separately.
Our goal now is to have a centralized asset loading mechanism and avoid the hassle of having to reference all our assets in a Blazor page or component. Basically, we’ll be moving from this
<Spritesheet Source="assets/planet1.json" OnModelLoaded="@OnAssetsLoaded" /> <Spritesheet Source="assets/planet2.json" OnModelLoaded="@OnAssetsLoaded" /> <Spritesheet Source="assets/planet3.json" OnModelLoaded="@OnAssetsLoaded" /> <Spritesheet Source="assets/planet4.json" OnModelLoaded="@OnAssetsLoaded" /> <Spritesheet Source="assets/planet5.json" OnModelLoaded="@OnAssetsLoaded" /> <Spritesheet Source="assets/planet6.json" OnModelLoaded="@OnAssetsLoaded" />
to something much more simple:
<Assets Source="assets/assets.json" OnLoaded="@OnAssetsLoaded" />
Nice, isn’t it?
The idea is quite simple actually: instead of referencing each asset one by one (let it be a sprite, sound, animation, whatever), we’re going to load a single JSON file containing the assets list along with its type:
[ { "path": "assets/enemyRed1.png", "type": "sprite" }, { "path": "assets/meteorBrown_big1.png", "type": "sprite" }, { "path": "assets/playerShip2_green.png", "type": "sprite" } ]
At this point, all we have to do is implement a Blazor Component that can parse this data, download the files and inject them into our Game. You can find the full code here, but let’s see the gist:
protected override async Task OnInitializedAsync() { var items = await Http.GetFromJsonAsync<AssetData[]>(this.Source); foreach (var item in items) { IAsset asset = null; if (item.type == "sprite") asset = await this.AssetsResolver.Load<Sprite>(item.path); if (null != asset) _items.Add(new Tuple<IAsset, AssetData>(asset, item)); } await this.OnLoaded.InvokeAsync(this); }
Here we download the file pointed by the Source property, loop over each item and use the AssetResolver instance to load it given the type. In our browser’s network tab we would see something like this:
Once loaded, the items will be rendered into the page in a simple loop:
<div class="assets"> @foreach (var item in _items) { // render item by type } </div>
The AssetsResolver class will basically serve two purposes: loading the assets from the server and, well, resolve them when we need during the game (more on this later). Each asset type has to be registered in the Composition Root. An AssetLoaderFactory will be leveraged to do the matching type/loader:
public class AssetLoaderFactory : IAssetLoaderFactory { private readonly IDictionary<Type, object> _loaders; public AssetLoaderFactory() { _loaders = new Dictionary<Type, object>(); } public void Register<TA>(IAssetLoader<TA> loader) where TA : IAsset { var type = typeof(TA); if (!_loaders.ContainsKey(type)) _loaders.Add(type, null); _loaders[type] = loader; } public IAssetLoader<TA> Get<TA>() where TA : IAsset { var type = typeof(TA); if(!_loaders.ContainsKey(type)) throw new ArgumentOutOfRangeException($"invalid asset type: {type.FullName}"); return _loaders[type] as IAssetLoader<TA>; } }
In our demo we will have a single Asset Loader implementation, SpriteAssetLoader:
public class SpriteAssetLoader : IAssetLoader<Sprite> { private readonly HttpClient _httpClient; private readonly ILogger<SpriteAssetLoader> _logger; public SpriteAssetLoader(HttpClient httpClient, ILogger<SpriteAssetLoader> logger) { _httpClient = httpClient; _logger = logger; } public async ValueTask<Sprite> Load(string path) { _logger.LogInformation($"loading sprite from path: {path}"); var bytes = await _httpClient.GetByteArrayAsync(path); await using var stream = new MemoryStream(bytes); using var image = await SixLabors.ImageSharp.Image.LoadAsync(stream); var size = new Size(image.Width, image.Height); var elementRef = new ElementReference(Guid.NewGuid().ToString()); return new Sprite(path, elementRef, size, bytes, ImageFormatUtils.FromPath(path)); } }
We first download the image from the server into a stream and then load it in memory. I’m using ImageSharp for now as seems to be the only library at the moment to work in WebAssembly. I was evaluating SkiaSharp as well, but when I wrote the sample it was still not working properly.
The reason why we need to parse the image data is that we need some useful info, like width and height. Yes, we could add those as attributes elsewhere, but we might also want to do some processing, like changing contrast/saturation and so on.
So, this covers the loading part. Now with the easy part: using the assets in our Game. The code is not that different from last time: all we have to do is initialize our Scene Graph and populate it with Game Objects:
var player = new GameObject(); player.Components.Add<TransformComponent>(); var playerSpriteRenderer = player.Components.Add<SpriteRenderComponent>(); playerSpriteRenderer.Sprite = AssetsResolver.Get<Sprite>("assets/playerShip2_green.png"); SceneGraph.Root.AddChild(player);
Aaaand we’re done! The final result is going to look like more or less like this:
This is the last article of the Series. I really enjoyed writing again about gamedev stuff and seems a lot of people liked the articles. It also contributed to my MVP award, so that’s definitely a plus 🙂
I’ll probably add something more in the not-so-distant future, who knows. For now, thank you very much for reading, see you next time!
Update 7/2/2021: I decided to add another article to the Series, this time we’ll talk about collision detection!