Update README.md to include project description, developer documentation links, and license information.
CI / build-test (push) Successful in 1m6s

This commit is contained in:
Leonid Pershin
2026-06-11 04:03:07 +03:00
parent 31aba3aeee
commit ff2231a8ab
72 changed files with 4113 additions and 0 deletions
@@ -0,0 +1,180 @@
using System.Collections.Immutable;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
namespace MrGameEng.Assets.Generator;
/// <summary>
/// Incremental source generator producing typed asset handles. Asset files are passed in as
/// AdditionalFiles (glob over the game's Assets/ directory); for each known file type a
/// <c>static readonly AssetRef&lt;T&gt;</c> field is emitted, nested in static classes
/// mirroring the directory tree.
/// </summary>
[Generator]
public sealed class AssetHandlesGenerator : IIncrementalGenerator
{
private static readonly Dictionary<string, string> TypeByExtension = new(StringComparer.OrdinalIgnoreCase)
{
[".png"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D",
[".jpg"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D",
[".jpeg"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D",
[".bmp"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D",
[".ttf"] = "global::FontStashSharp.FontSystem",
[".otf"] = "global::FontStashSharp.FontSystem",
[".wav"] = "global::Microsoft.Xna.Framework.Audio.SoundEffect",
[".ogg"] = "global::MrGameEng.Core.MusicTrack",
[".mgfx"] = "global::Microsoft.Xna.Framework.Graphics.Effect",
};
/// <inheritdoc />
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var options = context.AnalyzerConfigOptionsProvider.Select(static (provider, _) =>
{
provider.GlobalOptions.TryGetValue("build_property.RootNamespace", out var ns);
provider.GlobalOptions.TryGetValue("build_property.MrGameEngAssetsClassName", out var className);
return (
Namespace: string.IsNullOrEmpty(ns) ? "Game" : ns!,
ClassName: string.IsNullOrEmpty(className) ? "GameAssets" : className!);
});
var assets = context.AdditionalTextsProvider
.Select(static (text, _) => ToAssetPath(text.Path))
.Where(static path => path is not null)
.Collect();
context.RegisterSourceOutput(assets.Combine(options), static (production, input) =>
production.AddSource("GameAssets.g.cs", SourceText.From(Emit(input.Left!, input.Right.Namespace, input.Right.ClassName), Encoding.UTF8)));
}
/// <summary>
/// Extracts the path relative to the "Assets" directory (forward slashes), or null when
/// the file is outside an Assets directory or has an unknown extension.
/// </summary>
internal static string? ToAssetPath(string fullPath)
{
var normalized = fullPath.Replace('\\', '/');
var marker = normalized.LastIndexOf("/Assets/", StringComparison.OrdinalIgnoreCase);
if (marker < 0)
{
return null;
}
var relative = normalized.Substring(marker + "/Assets/".Length);
var extension = Path.GetExtension(relative);
return TypeByExtension.ContainsKey(extension) ? relative : null;
}
private static string Emit(ImmutableArray<string?> paths, string ns, string className)
{
var root = new Node();
foreach (var path in paths.Sort(StringComparer.Ordinal))
{
var segments = path!.Split('/');
var node = root;
for (var i = 0; i < segments.Length - 1; i++)
{
node = node.Child(segments[i]);
}
node.Files.Add((segments[segments.Length - 1], path!));
}
var source = new StringBuilder();
source.AppendLine("// <auto-generated by MrGameEng.Assets.Generator />");
source.AppendLine($"namespace {ns};");
source.AppendLine();
source.AppendLine("/// <summary>Typed handles for every file under the Assets directory.</summary>");
source.AppendLine($"public static partial class {className}");
source.AppendLine("{");
EmitNode(source, root, indent: 1);
source.AppendLine("}");
return source.ToString();
}
private static void EmitNode(StringBuilder source, Node node, int indent)
{
var pad = new string(' ', indent * 4);
var usedNames = new HashSet<string>();
foreach (var (fileName, relativePath) in node.Files)
{
var type = TypeByExtension[Path.GetExtension(fileName)];
var name = Unique(usedNames, Identifier(Path.GetFileNameWithoutExtension(fileName)));
source.AppendLine($"{pad}/// <summary>{relativePath}</summary>");
source.AppendLine(
$"{pad}public static readonly global::MrGameEng.Assets.AssetRef<{type}> {name} = new(\"{relativePath}\");");
}
foreach (var pair in node.Children)
{
var name = Unique(usedNames, Identifier(pair.Key));
source.AppendLine($"{pad}/// <summary>{pair.Key}/</summary>");
source.AppendLine($"{pad}public static class {name}");
source.AppendLine($"{pad}{{");
EmitNode(source, pair.Value, indent + 1);
source.AppendLine($"{pad}}}");
}
}
/// <summary>Converts an arbitrary file or directory name to a PascalCase C# identifier.</summary>
internal static string Identifier(string name)
{
var result = new StringBuilder(name.Length);
var upperNext = true;
foreach (var c in name)
{
if (char.IsLetterOrDigit(c))
{
result.Append(upperNext ? char.ToUpperInvariant(c) : c);
upperNext = false;
}
else
{
upperNext = true;
}
}
if (result.Length == 0)
{
return "_";
}
if (char.IsDigit(result[0]))
{
result.Insert(0, '_');
}
return result.ToString();
}
private static string Unique(HashSet<string> used, string name)
{
var candidate = name;
var counter = 2;
while (!used.Add(candidate))
{
candidate = name + counter++;
}
return candidate;
}
private sealed class Node
{
public readonly SortedDictionary<string, Node> Children = new(StringComparer.Ordinal);
public readonly List<(string FileName, string RelativePath)> Files = [];
public Node Child(string name)
{
if (!Children.TryGetValue(name, out var child))
{
child = new Node();
Children.Add(name, child);
}
return child;
}
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Assets.Generator.Tests" />
</ItemGroup>
</Project>
+131
View File
@@ -0,0 +1,131 @@
using FontStashSharp;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
namespace MrGameEng.Assets;
/// <summary>
/// Loads raw asset files at runtime (no content pipeline) by typed <see cref="AssetRef{T}"/>
/// handles, caches them by path and owns their lifetime. Built-in loaders:
/// <c>Texture2D</c> (png/jpg, premultiplied), <c>SoundEffect</c> (wav),
/// <c>FontSystem</c> (ttf via FontStashSharp), <c>Effect</c> (precompiled .mgfx),
/// <c>MusicTrack</c> (ogg, streamed by the audio module). Register custom loaders
/// with <see cref="RegisterLoader{T}"/>.
/// </summary>
public sealed class AssetManager : IDisposable
{
/// <summary>Absolute path of the asset root directory.</summary>
public string RootPath { get; }
private readonly EngineContext _context;
private readonly Dictionary<(Type Type, string Path), object> _cache = new();
private readonly Dictionary<Type, Func<AssetManager, string, object>> _loaders = new();
/// <summary>
/// Creates a manager reading from <paramref name="rootPath"/> (relative paths are resolved
/// against the executable directory; default "Assets").
/// </summary>
public AssetManager(EngineContext context, string rootPath = "Assets")
{
_context = context;
RootPath = Path.GetFullPath(rootPath, AppContext.BaseDirectory);
RegisterLoader((manager, path) => LoadTexture(manager._context, path));
RegisterLoader((_, path) => LoadSoundEffect(path));
RegisterLoader((_, path) => LoadFontSystem(path));
RegisterLoader((manager, path) => LoadEffect(manager._context, path));
RegisterLoader((_, path) => new MusicTrack(path));
}
/// <summary>Loads (or returns the cached) asset for <paramref name="asset"/>.</summary>
public T Load<T>(AssetRef<T> asset) where T : class
{
var key = (typeof(T), asset.Path);
if (_cache.TryGetValue(key, out var cached))
{
return (T)cached;
}
if (!_loaders.TryGetValue(typeof(T), out var loader))
{
throw new InvalidOperationException($"No asset loader registered for type {typeof(T)}.");
}
var fullPath = ResolvePath(asset.Path);
if (!File.Exists(fullPath))
{
throw new FileNotFoundException($"Asset '{asset.Path}' not found at '{fullPath}'.", fullPath);
}
var loaded = (T)loader(this, fullPath);
_cache.Add(key, loaded);
return loaded;
}
/// <summary>Removes one asset from the cache, disposing it if disposable.</summary>
public void Unload<T>(AssetRef<T> asset) where T : class
{
var key = (typeof(T), asset.Path);
if (_cache.Remove(key, out var value) && value is IDisposable disposable)
{
disposable.Dispose();
}
}
/// <summary>Replaces or adds the loader used for assets of type <typeparamref name="T"/>.</summary>
public void RegisterLoader<T>(Func<AssetManager, string, T> loader) where T : class =>
_loaders[typeof(T)] = loader;
/// <summary>Resolves an asset-relative path to an absolute file path.</summary>
public string ResolvePath(string relativePath) =>
Path.GetFullPath(Path.Combine(RootPath, relativePath));
/// <summary>Disposes every cached asset and clears the cache.</summary>
public void Dispose()
{
foreach (var value in _cache.Values)
{
(value as IDisposable)?.Dispose();
}
_cache.Clear();
}
private static Texture2D LoadTexture(EngineContext context, string path)
{
using var stream = File.OpenRead(path);
return Texture2D.FromStream(context.GraphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha);
}
private static SoundEffect LoadSoundEffect(string path)
{
using var stream = File.OpenRead(path);
return SoundEffect.FromStream(stream);
}
private static FontSystem LoadFontSystem(string path)
{
var fontSystem = new FontSystem();
fontSystem.AddFont(File.ReadAllBytes(path));
return fontSystem;
}
private static Effect LoadEffect(EngineContext context, string path) =>
new(context.GraphicsDevice, File.ReadAllBytes(path));
}
/// <summary>Wires the assets module into the engine.</summary>
public static class AssetsEngineExtensions
{
/// <summary>
/// Creates the <see cref="AssetManager"/> and registers it as a service.
/// Call once at startup (e.g. in the first scene's <c>OnLoad</c>).
/// </summary>
public static AssetManager UseAssets(this EngineContext context, string rootPath = "Assets")
{
var manager = new AssetManager(context, rootPath);
context.Services.Add(manager);
return manager;
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace MrGameEng.Assets;
/// <summary>
/// Typed handle to an asset: a path relative to the asset root plus the asset's runtime type.
/// Instances are produced by the <c>MrGameEng.Assets.Generator</c> source generator —
/// game code should never construct them from string literals.
/// </summary>
/// <typeparam name="T">Runtime type the asset loads into (e.g. <c>Texture2D</c>).</typeparam>
/// <param name="Path">Path relative to the asset root, with forward slashes.</param>
public readonly record struct AssetRef<T>(string Path) where T : class
{
/// <inheritdoc />
public override string ToString() => $"{typeof(T).Name}:{Path}";
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FontStashSharp.MonoGame" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+46
View File
@@ -0,0 +1,46 @@
using Microsoft.Xna.Framework.Audio;
using MrGameEng.Core;
namespace MrGameEng.Audio;
/// <summary>
/// Sound-effect playback with a module-level volume, plus the <see cref="Music"/> player.
/// Registered as a service via <c>context.UseAudio()</c>.
/// </summary>
public sealed class AudioManager : IDisposable
{
/// <summary>The streaming music player.</summary>
public MusicPlayer Music { get; } = new();
/// <summary>Volume multiplier applied to every sound effect, 0..1.</summary>
public float SoundVolume
{
get => _soundVolume;
set => _soundVolume = Math.Clamp(value, 0f, 1f);
}
private float _soundVolume = 1f;
/// <summary>Plays a sound effect (fire and forget).</summary>
/// <param name="sound">The loaded sound effect.</param>
/// <param name="volume">Per-play volume 0..1, multiplied with <see cref="SoundVolume"/>.</param>
/// <param name="pitch">Pitch offset in octaves, -1..1.</param>
/// <param name="pan">Stereo pan, -1 (left) .. 1 (right).</param>
public void Play(SoundEffect sound, float volume = 1f, float pitch = 0f, float pan = 0f) =>
sound.Play(Math.Clamp(volume, 0f, 1f) * _soundVolume, pitch, pan);
/// <inheritdoc />
public void Dispose() => Music.Dispose();
}
/// <summary>Wires the audio module into the engine.</summary>
public static class AudioEngineExtensions
{
/// <summary>Creates the <see cref="AudioManager"/> and registers it as a service. Call once at startup.</summary>
public static AudioManager UseAudio(this EngineContext context)
{
var manager = new AudioManager();
context.Services.Add(manager);
return manager;
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NVorbis" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+113
View File
@@ -0,0 +1,113 @@
using Microsoft.Xna.Framework.Audio;
using MrGameEng.Core;
using NVorbis;
namespace MrGameEng.Audio;
/// <summary>
/// Streams ogg music from disk through a <see cref="DynamicSoundEffectInstance"/> using NVorbis.
/// One track plays at a time; samples are decoded on demand in ~0.5 s buffers, so even long
/// tracks use almost no memory.
/// </summary>
public sealed class MusicPlayer : IDisposable
{
private const int BufferedSubmissions = 3;
private VorbisReader? _reader;
private DynamicSoundEffectInstance? _instance;
private float[] _sampleBuffer = [];
private byte[] _byteBuffer = [];
private bool _loop;
private float _volume = 1f;
/// <summary>Volume 0..1 applied to the playing and future tracks.</summary>
public float Volume
{
get => _volume;
set
{
_volume = Math.Clamp(value, 0f, 1f);
if (_instance is not null)
{
_instance.Volume = _volume;
}
}
}
/// <summary>True while a track is playing (not stopped or paused).</summary>
public bool IsPlaying => _instance?.State == SoundState.Playing;
/// <summary>Starts streaming <paramref name="track"/>, stopping the previous one.</summary>
public void Play(MusicTrack track, bool loop = true)
{
Stop();
_loop = loop;
_reader = new VorbisReader(track.FullPath);
// ~0.5 seconds of samples per submitted buffer.
var samplesPerBuffer = _reader.SampleRate * _reader.Channels / 2;
_sampleBuffer = new float[samplesPerBuffer];
_byteBuffer = new byte[samplesPerBuffer * 2];
_instance = new DynamicSoundEffectInstance(
_reader.SampleRate,
_reader.Channels == 1 ? AudioChannels.Mono : AudioChannels.Stereo)
{
Volume = _volume,
};
_instance.BufferNeeded += (_, _) => FillBuffers();
FillBuffers();
_instance.Play();
}
/// <summary>Pauses the current track.</summary>
public void Pause() => _instance?.Pause();
/// <summary>Resumes a paused track.</summary>
public void Resume() => _instance?.Resume();
/// <summary>Stops playback and releases the decoder.</summary>
public void Stop()
{
_instance?.Dispose();
_instance = null;
_reader?.Dispose();
_reader = null;
}
/// <inheritdoc />
public void Dispose() => Stop();
private void FillBuffers()
{
if (_instance is null || _reader is null)
{
return;
}
while (_instance.PendingBufferCount < BufferedSubmissions)
{
var read = _reader.ReadSamples(_sampleBuffer, 0, _sampleBuffer.Length);
if (read == 0)
{
if (!_loop)
{
return;
}
_reader.SamplePosition = 0;
continue;
}
for (var i = 0; i < read; i++)
{
var sample = (short)(Math.Clamp(_sampleBuffer[i], -1f, 1f) * short.MaxValue);
_byteBuffer[i * 2] = (byte)sample;
_byteBuffer[i * 2 + 1] = (byte)(sample >> 8);
}
_instance.SubmitBuffer(_byteBuffer, 0, read * 2);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using Microsoft.Xna.Framework.Graphics;
namespace MrGameEng.Core;
/// <summary>
/// Root object handed to scenes and systems: time, scene manager, services and graphics device.
/// Created by <see cref="GameHost"/>; can also be created standalone for headless tests.
/// </summary>
public sealed class EngineContext
{
/// <summary>Engine time service.</summary>
public GameClock Clock { get; } = new();
/// <summary>Scene manager owning the active scene.</summary>
public SceneManager Scenes { get; }
/// <summary>Registry of module services (input, audio, assets, …).</summary>
public ServiceRegistry Services { get; } = new();
/// <summary>
/// The graphics device. Available once the host is initialized;
/// throws when accessed in a headless context (unit tests).
/// </summary>
public GraphicsDevice GraphicsDevice =>
_graphicsDevice ?? throw new InvalidOperationException("GraphicsDevice is not available (headless context).");
/// <summary>True when a graphics device is attached.</summary>
public bool HasGraphicsDevice => _graphicsDevice is not null;
private GraphicsDevice? _graphicsDevice;
/// <summary>Creates a context. Games normally never create one themselves — <see cref="GameHost"/> does.</summary>
public EngineContext()
{
Scenes = new SceneManager(this);
}
internal void AttachGraphicsDevice(GraphicsDevice device) => _graphicsDevice = device;
}
+42
View File
@@ -0,0 +1,42 @@
namespace MrGameEng.Core;
/// <summary>
/// Engine time service: per-frame delta, total elapsed time, time scaling and frame counter.
/// Advanced once per frame by <see cref="GameHost"/>.
/// </summary>
public sealed class GameClock
{
/// <summary>Seconds elapsed since the previous frame, multiplied by <see cref="TimeScale"/>.</summary>
public float DeltaTime { get; private set; }
/// <summary>Seconds elapsed since the previous frame, unaffected by <see cref="TimeScale"/>.</summary>
public float UnscaledDeltaTime { get; private set; }
/// <summary>Total scaled time in seconds since the game started.</summary>
public double TotalTime { get; private set; }
/// <summary>Total unscaled time in seconds since the game started.</summary>
public double UnscaledTotalTime { get; private set; }
/// <summary>Multiplier applied to <see cref="DeltaTime"/>. 0 pauses gameplay, 1 is real time. Never negative.</summary>
public float TimeScale
{
get => _timeScale;
set => _timeScale = value < 0f ? 0f : value;
}
/// <summary>Number of completed frames since the game started.</summary>
public long FrameCount { get; private set; }
private float _timeScale = 1f;
/// <summary>Advances the clock by one frame. Called by the host; games should not call this.</summary>
public void Advance(float unscaledDeltaSeconds)
{
UnscaledDeltaTime = unscaledDeltaSeconds;
DeltaTime = unscaledDeltaSeconds * _timeScale;
UnscaledTotalTime += unscaledDeltaSeconds;
TotalTime += DeltaTime;
FrameCount++;
}
}
+77
View File
@@ -0,0 +1,77 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Core;
/// <summary>
/// The engine's game loop host. Wraps MonoGame's <see cref="Game"/>: owns the
/// <see cref="EngineContext"/>, advances the <see cref="GameClock"/> and drives the
/// active scene's update and draw phases.
/// </summary>
public class GameHost : Game
{
/// <summary>Engine context shared with scenes and systems.</summary>
public EngineContext Context { get; } = new();
/// <summary>The graphics device manager created by the host.</summary>
public GraphicsDeviceManager Graphics { get; }
private readonly GameHostOptions _options;
private readonly Scene _initialScene;
/// <summary>Creates a host that starts with <paramref name="initialScene"/>.</summary>
public GameHost(GameHostOptions options, Scene initialScene)
{
_options = options;
_initialScene = initialScene;
Graphics = new GraphicsDeviceManager(this)
{
PreferredBackBufferWidth = options.Width,
PreferredBackBufferHeight = options.Height,
IsFullScreen = options.Fullscreen,
SynchronizeWithVerticalRetrace = options.VSync,
};
IsMouseVisible = true;
IsFixedTimeStep = options.FixedTimeStep;
if (options.FixedTimeStep)
{
TargetElapsedTime = TimeSpan.FromSeconds(1.0 / options.TargetFps);
}
}
/// <inheritdoc />
protected override void Initialize()
{
Window.Title = _options.Title;
Window.AllowUserResizing = _options.AllowResizing;
Context.AttachGraphicsDevice(GraphicsDevice);
Context.Services.Add(Window);
base.Initialize();
Context.Scenes.Switch(_initialScene);
}
/// <inheritdoc />
protected override void Update(GameTime gameTime)
{
Context.Clock.Advance((float)gameTime.ElapsedGameTime.TotalSeconds);
Context.Scenes.Update(Context.Clock);
base.Update(gameTime);
}
/// <inheritdoc />
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(_options.ClearColor);
Context.Scenes.Draw(Context.Clock);
base.Draw(gameTime);
}
/// <inheritdoc />
protected override void OnExiting(object sender, ExitingEventArgs args)
{
Context.Scenes.Switch(null);
Context.Scenes.ApplyPending();
base.OnExiting(sender, args);
}
}
+34
View File
@@ -0,0 +1,34 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Core;
/// <summary>Window and loop settings for <see cref="GameHost"/>.</summary>
public sealed class GameHostOptions
{
/// <summary>Window title.</summary>
public string Title { get; set; } = "MrGameEng";
/// <summary>Backbuffer width in pixels.</summary>
public int Width { get; set; } = 1280;
/// <summary>Backbuffer height in pixels.</summary>
public int Height { get; set; } = 720;
/// <summary>Borderless fullscreen instead of a window.</summary>
public bool Fullscreen { get; set; }
/// <summary>Synchronize presentation with the display's vertical retrace.</summary>
public bool VSync { get; set; } = true;
/// <summary>Run updates on a fixed timestep (<see cref="TargetFps"/>) instead of as fast as possible.</summary>
public bool FixedTimeStep { get; set; }
/// <summary>Target update rate when <see cref="FixedTimeStep"/> is enabled.</summary>
public int TargetFps { get; set; } = 60;
/// <summary>Color the backbuffer is cleared to each frame.</summary>
public Color ClearColor { get; set; } = Color.CornflowerBlue;
/// <summary>Allow the user to resize the window.</summary>
public bool AllowResizing { get; set; } = true;
}
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MonoGame.Framework.DesktopGL" />
<PackageReference Include="Friflo.Engine.ECS" />
</ItemGroup>
</Project>
+8
View File
@@ -0,0 +1,8 @@
namespace MrGameEng.Core;
/// <summary>
/// An ogg music file reference. Resolved by the assets module; streamed from disk by the
/// audio module's <c>MusicPlayer</c> rather than loaded into memory.
/// </summary>
/// <param name="FullPath">Absolute path of the ogg file.</param>
public sealed record MusicTrack(string FullPath);
+62
View File
@@ -0,0 +1,62 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
namespace MrGameEng.Core;
/// <summary>
/// A scene owns its ECS world (<see cref="EntityStore"/>) and two system roots:
/// <see cref="UpdateSystems"/> for game logic and <see cref="DrawSystems"/> for rendering.
/// Override <see cref="OnLoad"/> to create entities and register systems.
/// </summary>
public abstract class Scene
{
/// <summary>The ECS world of this scene.</summary>
public EntityStore Store { get; } = new();
/// <summary>Systems executed every update tick, in registration order.</summary>
public SystemRoot UpdateSystems { get; }
/// <summary>Systems executed every draw tick, in registration order.</summary>
public SystemRoot DrawSystems { get; }
/// <summary>Engine context. Valid from <see cref="OnLoad"/> until <see cref="OnUnload"/>.</summary>
public EngineContext Context => _context ?? throw new InvalidOperationException("Scene is not loaded.");
/// <summary>True while the scene is the active, loaded scene.</summary>
public bool IsLoaded => _context is not null;
private EngineContext? _context;
/// <summary>Initializes the scene's ECS world and system roots.</summary>
protected Scene()
{
UpdateSystems = new SystemRoot(Store, "Update");
DrawSystems = new SystemRoot(Store, "Draw");
}
/// <summary>Called once when the scene becomes active: create entities, add systems.</summary>
protected abstract void OnLoad();
/// <summary>Called once when the scene is replaced or the game exits. Release scene resources here.</summary>
protected virtual void OnUnload() { }
/// <summary>Runs the update phase. Called by <see cref="SceneManager"/>.</summary>
public virtual void Update(GameClock clock) =>
UpdateSystems.Update(new UpdateTick(clock.DeltaTime, (float)clock.TotalTime));
/// <summary>Runs the draw phase. Called by <see cref="SceneManager"/>.</summary>
public virtual void Draw(GameClock clock) =>
DrawSystems.Update(new UpdateTick(clock.DeltaTime, (float)clock.TotalTime));
internal void Load(EngineContext context)
{
_context = context;
OnLoad();
}
internal void Unload()
{
OnUnload();
_context = null;
}
}
+51
View File
@@ -0,0 +1,51 @@
namespace MrGameEng.Core;
/// <summary>
/// Owns the active <see cref="Scene"/>. Scene switches are deferred to the start of the
/// next update so a scene is never unloaded in the middle of its own frame.
/// </summary>
public sealed class SceneManager
{
/// <summary>The active scene, or null before the first switch is applied.</summary>
public Scene? Current { get; private set; }
private readonly EngineContext _context;
private Scene? _pending;
private bool _hasPending;
internal SceneManager(EngineContext context) => _context = context;
/// <summary>
/// Requests a switch to <paramref name="scene"/>. The current scene is unloaded and the new
/// one loaded at the start of the next update tick. Passing null unloads the current scene.
/// </summary>
public void Switch(Scene? scene)
{
_pending = scene;
_hasPending = true;
}
/// <summary>Applies a pending switch, then updates the active scene. Called by the host.</summary>
public void Update(GameClock clock)
{
ApplyPending();
Current?.Update(clock);
}
/// <summary>Draws the active scene. Called by the host.</summary>
public void Draw(GameClock clock) => Current?.Draw(clock);
internal void ApplyPending()
{
if (!_hasPending)
{
return;
}
_hasPending = false;
Current?.Unload();
Current = _pending;
_pending = null;
Current?.Load(_context);
}
}
+33
View File
@@ -0,0 +1,33 @@
namespace MrGameEng.Core;
/// <summary>
/// Minimal service locator used by engine modules to expose their services
/// (input, audio, assets, …) to scenes and systems without coupling modules to each other.
/// </summary>
public sealed class ServiceRegistry
{
private readonly Dictionary<Type, object> _services = new();
/// <summary>Registers a service instance under type <typeparamref name="T"/>. Throws if already registered.</summary>
public void Add<T>(T service) where T : class
{
if (!_services.TryAdd(typeof(T), service))
{
throw new InvalidOperationException($"Service of type {typeof(T)} is already registered.");
}
}
/// <summary>Returns the registered service of type <typeparamref name="T"/>. Throws if missing.</summary>
public T Get<T>() where T : class
{
return _services.TryGetValue(typeof(T), out var service)
? (T)service
: throw new InvalidOperationException($"Service of type {typeof(T)} is not registered.");
}
/// <summary>Returns the registered service of type <typeparamref name="T"/> or null.</summary>
public T? GetOrDefault<T>() where T : class
{
return _services.TryGetValue(typeof(T), out var service) ? (T)service : null;
}
}
+32
View File
@@ -0,0 +1,32 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
namespace MrGameEng.Graphics;
/// <summary>
/// Orthographic 2D camera component. The renderer uses the first entity that has this
/// component as the active camera. Create via the constructor — the struct default has zero zoom.
/// </summary>
public struct Camera : IComponent
{
/// <summary>World position the camera looks at (center of the view).</summary>
public Vector2 Position;
/// <summary>Zoom factor. 1 = one world unit per virtual pixel; 2 = twice as close.</summary>
public float Zoom;
/// <summary>Camera roll in radians, clockwise.</summary>
public float Rotation;
/// <summary>Optional world-bounds clamp: the view never leaves this rectangle (when it fits).</summary>
public RectF? Bounds;
/// <summary>Creates a camera centered at <paramref name="position"/>.</summary>
public Camera(Vector2 position, float zoom = 1f, float rotation = 0f, RectF? bounds = null)
{
Position = position;
Zoom = zoom;
Rotation = rotation;
Bounds = bounds;
}
}
+120
View File
@@ -0,0 +1,120 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Graphics;
/// <summary>Maps physical screen pixels to virtual-resolution pixels (letterbox scaling).</summary>
public readonly record struct ViewportMapping(Vector2 Offset, float Scale)
{
/// <summary>Identity mapping (no letterbox).</summary>
public static readonly ViewportMapping Identity = new(Vector2.Zero, 1f);
}
/// <summary>Per-frame camera matrices and derived data, computed by <see cref="CameraMath"/>.</summary>
public readonly struct CameraState
{
/// <summary>World → virtual-screen transform of the active camera.</summary>
public required Matrix View { get; init; }
/// <summary>Virtual-screen → NDC orthographic projection.</summary>
public required Matrix Projection { get; init; }
/// <summary>Inverse of <see cref="View"/>.</summary>
public required Matrix InverseView { get; init; }
/// <summary>World-space rectangle visible through the camera; used for culling.</summary>
public required RectF CullRect { get; init; }
/// <summary>Virtual resolution width in pixels.</summary>
public required int VirtualWidth { get; init; }
/// <summary>Virtual resolution height in pixels.</summary>
public required int VirtualHeight { get; init; }
/// <summary>Physical-screen to virtual-pixel mapping.</summary>
public required ViewportMapping Mapping { get; init; }
/// <summary>Converts a physical screen point to world coordinates.</summary>
public Vector2 ScreenToWorld(Vector2 screen)
{
var virtualPoint = (screen - Mapping.Offset) / Mapping.Scale;
return Vector2.Transform(virtualPoint, InverseView);
}
/// <summary>Converts a world point to physical screen coordinates.</summary>
public Vector2 WorldToScreen(Vector2 world)
{
var virtualPoint = Vector2.Transform(world, View);
return virtualPoint * Mapping.Scale + Mapping.Offset;
}
}
/// <summary>Pure math for the orthographic 2D camera. Y axis points down, rotation is clockwise.</summary>
public static class CameraMath
{
/// <summary>Computes the full camera state for a frame.</summary>
public static CameraState Compute(in Camera camera, int virtualWidth, int virtualHeight, ViewportMapping mapping)
{
var zoom = camera.Zoom <= 0f ? 1f : camera.Zoom;
var position = ClampToBounds(camera, virtualWidth, virtualHeight, zoom);
var view =
Matrix.CreateTranslation(-position.X, -position.Y, 0f) *
Matrix.CreateRotationZ(-camera.Rotation) *
Matrix.CreateScale(zoom, zoom, 1f) *
Matrix.CreateTranslation(virtualWidth / 2f, virtualHeight / 2f, 0f);
var inverseView = Matrix.Invert(view);
return new CameraState
{
View = view,
Projection = Matrix.CreateOrthographicOffCenter(0f, virtualWidth, virtualHeight, 0f, 0f, 1f),
InverseView = inverseView,
CullRect = ComputeCullRect(inverseView, virtualWidth, virtualHeight),
VirtualWidth = virtualWidth,
VirtualHeight = virtualHeight,
Mapping = mapping,
};
}
/// <summary>
/// Computes the letterbox mapping that fits the virtual resolution into a physical
/// viewport, preserving aspect ratio and centering.
/// </summary>
public static ViewportMapping ComputeMapping(int screenWidth, int screenHeight, int virtualWidth, int virtualHeight)
{
var scale = MathF.Min((float)screenWidth / virtualWidth, (float)screenHeight / virtualHeight);
var offset = new Vector2(screenWidth - virtualWidth * scale, screenHeight - virtualHeight * scale) / 2f;
return new ViewportMapping(offset, scale);
}
private static Vector2 ClampToBounds(in Camera camera, int virtualWidth, int virtualHeight, float zoom)
{
if (camera.Bounds is not { } bounds)
{
return camera.Position;
}
// Clamp uses unrotated view extents; with camera roll the clamp is approximate.
var halfW = virtualWidth / (2f * zoom);
var halfH = virtualHeight / (2f * zoom);
return new Vector2(
ClampAxis(camera.Position.X, bounds.Left + halfW, bounds.Right - halfW),
ClampAxis(camera.Position.Y, bounds.Top + halfH, bounds.Bottom - halfH));
}
private static float ClampAxis(float value, float min, float max) =>
min > max ? (min + max) / 2f : Math.Clamp(value, min, max);
private static RectF ComputeCullRect(in Matrix inverseView, int virtualWidth, int virtualHeight)
{
var c0 = Vector2.Transform(Vector2.Zero, inverseView);
var c1 = Vector2.Transform(new Vector2(virtualWidth, 0f), inverseView);
var c2 = Vector2.Transform(new Vector2(0f, virtualHeight), inverseView);
var c3 = Vector2.Transform(new Vector2(virtualWidth, virtualHeight), inverseView);
var min = Vector2.Min(Vector2.Min(c0, c1), Vector2.Min(c2, c3));
var max = Vector2.Max(Vector2.Max(c0, c1), Vector2.Max(c2, c3));
return RectF.FromCorners(min, max);
}
}
+41
View File
@@ -0,0 +1,41 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Graphics;
/// <summary>Conservative visibility tests used before sprites are written to the batcher.</summary>
public static class CullingMath
{
/// <summary>
/// Computes the world-space center and a conservative bounding-circle radius of a sprite
/// (valid for any rotation), given its transform, region size in pixels and origin.
/// </summary>
public static (Vector2 Center, float Radius) SpriteBoundingCircle(
in Transform2D transform, float regionWidth, float regionHeight, Vector2 origin)
{
var scaledW = regionWidth * transform.Scale.X;
var scaledH = regionHeight * transform.Scale.Y;
// Offset from the pivot (= transform.Position) to the sprite's geometric center.
var toCenter = new Vector2(
scaledW / 2f - origin.X * transform.Scale.X,
scaledH / 2f - origin.Y * transform.Scale.Y);
var (sin, cos) = MathF.SinCos(transform.Rotation);
var center = transform.Position + new Vector2(
toCenter.X * cos - toCenter.Y * sin,
toCenter.X * sin + toCenter.Y * cos);
var radius = 0.5f * MathF.Sqrt(scaledW * scaledW + scaledH * scaledH);
return (center, radius);
}
/// <summary>True when the circle overlaps the rectangle.</summary>
public static bool CircleIntersectsRect(Vector2 center, float radius, in RectF rect)
{
var nearestX = Math.Clamp(center.X, rect.Left, rect.Right);
var nearestY = Math.Clamp(center.Y, rect.Top, rect.Bottom);
var dx = center.X - nearestX;
var dy = center.Y - nearestY;
return dx * dx + dy * dy <= radius * radius;
}
}
+62
View File
@@ -0,0 +1,62 @@
namespace MrGameEng.Graphics;
/// <summary>Compact identifier of a render layer. Obtained from <see cref="LayerRegistry.Register"/>.</summary>
public readonly record struct LayerId(byte Value)
{
/// <summary>The default layer (the first one registered).</summary>
public static readonly LayerId Default = new(0);
}
/// <summary>Coordinate space a layer is drawn in.</summary>
public enum LayerSpace
{
/// <summary>Drawn through the active camera's transform.</summary>
World,
/// <summary>Drawn in screen coordinates, ignoring the camera (HUD, UI). Never culled.</summary>
Screen,
}
/// <summary>How sprites are ordered within a layer.</summary>
public enum LayerSortMode
{
/// <summary>Order by the sprite's <see cref="Sprite.Depth"/> value (smaller = drawn first).</summary>
Depth,
/// <summary>Order by world Y position (top-down games: lower on screen = drawn in front).</summary>
YSort,
}
/// <summary>A registered render layer.</summary>
public sealed record RenderLayer(LayerId Id, string Name, LayerSpace Space, LayerSortMode SortMode);
/// <summary>
/// Registry of render layers. Layers are registered up front (typically when the renderer is
/// created) and drawn in registration order. Maximum 256 layers.
/// </summary>
public sealed class LayerRegistry
{
private readonly List<RenderLayer> _layers = [];
/// <summary>Creates a registry containing the built-in "Default" world layer.</summary>
public LayerRegistry() => Register("Default");
/// <summary>Number of registered layers.</summary>
public int Count => _layers.Count;
/// <summary>Registers a layer drawn after all previously registered ones.</summary>
public LayerId Register(string name, LayerSpace space = LayerSpace.World, LayerSortMode sortMode = LayerSortMode.Depth)
{
if (_layers.Count == 256)
{
throw new InvalidOperationException("Maximum number of render layers (256) reached.");
}
var id = new LayerId((byte)_layers.Count);
_layers.Add(new RenderLayer(id, name, space, sortMode));
return id;
}
/// <summary>Returns the layer with the given id.</summary>
public RenderLayer this[LayerId id] => _layers[id.Value];
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+34
View File
@@ -0,0 +1,34 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Graphics;
/// <summary>Axis-aligned rectangle with float coordinates (MonoGame's <see cref="Rectangle"/> is int-only).</summary>
public readonly record struct RectF(float X, float Y, float Width, float Height)
{
/// <summary>Left edge.</summary>
public float Left => X;
/// <summary>Top edge.</summary>
public float Top => Y;
/// <summary>Right edge.</summary>
public float Right => X + Width;
/// <summary>Bottom edge.</summary>
public float Bottom => Y + Height;
/// <summary>Center point.</summary>
public Vector2 Center => new(X + Width / 2f, Y + Height / 2f);
/// <summary>Creates the smallest rectangle containing both corner points.</summary>
public static RectF FromCorners(Vector2 min, Vector2 max) =>
new(min.X, min.Y, max.X - min.X, max.Y - min.Y);
/// <summary>True when this rectangle and <paramref name="other"/> overlap.</summary>
public bool Intersects(in RectF other) =>
other.Left < Right && Left < other.Right && other.Top < Bottom && Top < other.Bottom;
/// <summary>True when the point lies inside the rectangle.</summary>
public bool Contains(Vector2 point) =>
point.X >= Left && point.X < Right && point.Y >= Top && point.Y < Bottom;
}
+66
View File
@@ -0,0 +1,66 @@
using Friflo.Engine.ECS.Systems;
namespace MrGameEng.Graphics;
/// <summary>
/// First draw system: finds the active camera entity (the first one with a <see cref="Camera"/>
/// component) and begins the renderer frame. Without a camera entity a default camera showing
/// world origin at the top-left corner is used.
/// </summary>
public sealed class CameraSystem : QuerySystem<Camera>
{
private readonly Renderer2D _renderer;
/// <summary>Creates the system for <paramref name="renderer"/>.</summary>
public CameraSystem(Renderer2D renderer) => _renderer = renderer;
/// <inheritdoc />
protected override void OnUpdate()
{
foreach (var (cameras, _) in Query.Chunks)
{
if (cameras.Length > 0)
{
_renderer.BeginFrame(in cameras.Span[0]);
return;
}
}
_renderer.BeginFrameWithDefaultCamera();
}
}
/// <summary>Submits every entity that has both <see cref="Sprite"/> and <see cref="Transform2D"/>.</summary>
public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D>
{
private readonly Renderer2D _renderer;
/// <summary>Creates the system for <paramref name="renderer"/>.</summary>
public SpriteRenderSystem(Renderer2D renderer) => _renderer = renderer;
/// <inheritdoc />
protected override void OnUpdate()
{
foreach (var (sprites, transforms, _) in Query.Chunks)
{
var s = sprites.Span;
var t = transforms.Span;
for (var i = 0; i < s.Length; i++)
{
_renderer.Submit(in t[i], in s[i]);
}
}
}
}
/// <summary>Last draw system: sorts the frame and issues the draw calls.</summary>
public sealed class RenderFlushSystem : BaseSystem
{
private readonly Renderer2D _renderer;
/// <summary>Creates the system for <paramref name="renderer"/>.</summary>
public RenderFlushSystem(Renderer2D renderer) => _renderer = renderer;
/// <inheritdoc />
protected override void OnUpdateGroup() => _renderer.EndFrame();
}
+324
View File
@@ -0,0 +1,324 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace MrGameEng.Graphics;
/// <summary>
/// The engine's 2D renderer: a sprite batcher over dynamic vertex buffers.
/// Per frame: <see cref="BeginFrame"/> (camera) → <see cref="Submit"/> per sprite (with culling)
/// → <see cref="EndFrame"/> (sort layer → depth → texture, build vertices, issue draw calls).
/// Registered as a service; scenes attach it via <c>scene.UseRenderer2D()</c>.
/// </summary>
public sealed class Renderer2D : IDisposable
{
private const int MaxQuadsPerDraw = 8192;
/// <summary>Render layer registry. Register layers before the first frame.</summary>
public LayerRegistry Layers { get; } = new();
/// <summary>Camera state of the current frame. Valid between BeginFrame and the next BeginFrame.</summary>
public CameraState Camera { get; private set; }
/// <summary>Draw calls issued by the last <see cref="EndFrame"/>.</summary>
public int DrawCalls { get; private set; }
/// <summary>Sprites accepted by <see cref="Submit"/> this frame.</summary>
public int SubmittedSprites { get; private set; }
/// <summary>Sprites rejected by culling this frame.</summary>
public int CulledSprites { get; private set; }
private readonly GraphicsDevice _device;
private readonly Renderer2DOptions _options;
private readonly SpriteBatcher _batcher;
private readonly BasicEffect _effect;
private readonly IndexBuffer _indexBuffer;
private DynamicVertexBuffer _vertexBuffer;
private VertexPositionColorTexture[] _vertices;
private CameraState _screenCamera;
private bool _begun;
/// <summary>Creates the renderer. One instance per game is enough.</summary>
public Renderer2D(GraphicsDevice device, Renderer2DOptions? options = null)
{
_device = device;
_options = options ?? new Renderer2DOptions();
_batcher = new SpriteBatcher(_options.InitialCapacity);
_vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4];
_vertexBuffer = new DynamicVertexBuffer(
device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length, BufferUsage.WriteOnly);
_effect = new BasicEffect(device)
{
TextureEnabled = true,
VertexColorEnabled = true,
World = Matrix.Identity,
};
_indexBuffer = CreateQuadIndexBuffer(device);
}
/// <summary>Begins a frame with the given camera. Called by <see cref="CameraSystem"/>.</summary>
public void BeginFrame(in Camera camera)
{
var (virtualW, virtualH, mapping) = ResolveVirtualResolution();
Camera = CameraMath.Compute(camera, virtualW, virtualH, mapping);
_screenCamera = CameraMath.Compute(
new Camera(new Vector2(virtualW / 2f, virtualH / 2f)), virtualW, virtualH, mapping);
_batcher.Clear();
SubmittedSprites = 0;
CulledSprites = 0;
_begun = true;
}
/// <summary>
/// Begins a frame with a default camera that shows the world origin at the top-left
/// corner of the screen. Used when the scene has no camera entity.
/// </summary>
public void BeginFrameWithDefaultCamera()
{
var (virtualW, virtualH, _) = ResolveVirtualResolution();
var camera = new Camera(new Vector2(virtualW / 2f, virtualH / 2f));
BeginFrame(in camera);
}
/// <summary>Submits one sprite. Invisible sprites (outside the camera) are culled here.</summary>
public void Submit(in Transform2D transform, in Sprite sprite)
{
if (!_begun)
{
throw new InvalidOperationException("Submit called outside BeginFrame/EndFrame (is CameraSystem registered first?).");
}
if (sprite.Region is not { } region)
{
return;
}
var layer = Layers[sprite.Layer];
var (center, radius) = CullingMath.SpriteBoundingCircle(transform, region.Width, region.Height, sprite.Origin);
if (layer.Space == LayerSpace.World &&
!CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect))
{
CulledSprites++;
return;
}
var depth = layer.SortMode == LayerSortMode.YSort ? center.Y : sprite.Depth;
_batcher.Submit(
new SpriteInstance
{
Region = region,
Center = center,
HalfSize = new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y) / 2f,
Rotation = transform.Rotation,
Color = sprite.Color,
Flip = sprite.Flip,
Layer = sprite.Layer.Value,
},
SpriteSortKey.Make(sprite.Layer.Value, depth, region.TextureSortKey));
SubmittedSprites++;
}
/// <summary>Sorts, builds vertices and issues draw calls. Called by <see cref="RenderFlushSystem"/>.</summary>
public void EndFrame()
{
if (!_begun)
{
throw new InvalidOperationException("EndFrame called without BeginFrame.");
}
_begun = false;
DrawCalls = 0;
var order = _batcher.Sort();
if (order.Length == 0)
{
return;
}
EnsureVertexCapacity(order.Length * 4);
BuildVertices(order);
_vertexBuffer.SetData(_vertices, 0, order.Length * 4, SetDataOptions.Discard);
_device.BlendState = BlendState.AlphaBlend;
_device.SamplerStates[0] = _options.Sampler;
_device.DepthStencilState = DepthStencilState.None;
_device.RasterizerState = RasterizerState.CullNone;
_device.SetVertexBuffer(_vertexBuffer);
_device.Indices = _indexBuffer;
DrawBatches(order);
}
/// <summary>Converts a physical screen point to world coordinates using the current camera.</summary>
public Vector2 ScreenToWorld(Vector2 screen) => Camera.ScreenToWorld(screen);
/// <summary>Converts a world point to physical screen coordinates using the current camera.</summary>
public Vector2 WorldToScreen(Vector2 world) => Camera.WorldToScreen(world);
/// <inheritdoc />
public void Dispose()
{
_effect.Dispose();
_vertexBuffer.Dispose();
_indexBuffer.Dispose();
}
private (int Width, int Height, ViewportMapping Mapping) ResolveVirtualResolution()
{
var viewport = _device.Viewport;
if (_options.VirtualResolution is not { } virtualSize)
{
return (viewport.Width, viewport.Height, ViewportMapping.Identity);
}
return (virtualSize.X, virtualSize.Y,
CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y));
}
private void BuildVertices(ReadOnlySpan<int> order)
{
for (var i = 0; i < order.Length; i++)
{
ref readonly var instance = ref _batcher[order[i]];
var bounds = instance.Region.Bounds;
var texture = instance.Region.Texture;
var u0 = bounds.X / (float)texture.Width;
var v0 = bounds.Y / (float)texture.Height;
var u1 = (bounds.X + bounds.Width) / (float)texture.Width;
var v1 = (bounds.Y + bounds.Height) / (float)texture.Height;
if ((instance.Flip & SpriteFlip.X) != 0)
{
(u0, u1) = (u1, u0);
}
if ((instance.Flip & SpriteFlip.Y) != 0)
{
(v0, v1) = (v1, v0);
}
var (sin, cos) = MathF.SinCos(instance.Rotation);
var rx = new Vector2(instance.HalfSize.X * cos, instance.HalfSize.X * sin);
var ry = new Vector2(-instance.HalfSize.Y * sin, instance.HalfSize.Y * cos);
var center = instance.Center;
var vertex = i * 4;
_vertices[vertex + 0] = Vertex(center - rx - ry, instance.Color, u0, v0);
_vertices[vertex + 1] = Vertex(center + rx - ry, instance.Color, u1, v0);
_vertices[vertex + 2] = Vertex(center - rx + ry, instance.Color, u0, v1);
_vertices[vertex + 3] = Vertex(center + rx + ry, instance.Color, u1, v1);
}
}
private void DrawBatches(ReadOnlySpan<int> order)
{
var batchStart = 0;
ref readonly var first = ref _batcher[order[0]];
var currentTexture = first.Region.Texture;
var currentLayer = first.Layer;
ApplyLayerMatrices(currentLayer);
for (var i = 1; i <= order.Length; i++)
{
Texture2D? texture = null;
byte layer = 0;
if (i < order.Length)
{
ref readonly var instance = ref _batcher[order[i]];
texture = instance.Region.Texture;
layer = instance.Layer;
if (ReferenceEquals(texture, currentTexture) && layer == currentLayer)
{
continue;
}
}
DrawRange(currentTexture, batchStart, i - batchStart);
batchStart = i;
if (i < order.Length)
{
currentTexture = texture!;
if (layer != currentLayer)
{
currentLayer = layer;
ApplyLayerMatrices(currentLayer);
}
}
}
}
private void ApplyLayerMatrices(byte layer)
{
var state = Layers[new LayerId(layer)].Space == LayerSpace.Screen ? _screenCamera : Camera;
_effect.View = state.View;
_effect.Projection = state.Projection;
}
private void DrawRange(Texture2D texture, int firstQuad, int quadCount)
{
_effect.Texture = texture;
while (quadCount > 0)
{
var quads = Math.Min(quadCount, MaxQuadsPerDraw);
foreach (var pass in _effect.CurrentTechnique.Passes)
{
pass.Apply();
_device.DrawIndexedPrimitives(PrimitiveType.TriangleList, firstQuad * 4, 0, quads * 2);
DrawCalls++;
}
firstQuad += quads;
quadCount -= quads;
}
}
private void EnsureVertexCapacity(int vertexCount)
{
if (_vertices.Length >= vertexCount)
{
return;
}
var capacity = _vertices.Length;
while (capacity < vertexCount)
{
capacity *= 2;
}
_vertices = new VertexPositionColorTexture[capacity];
_vertexBuffer.Dispose();
_vertexBuffer = new DynamicVertexBuffer(
_device, VertexPositionColorTexture.VertexDeclaration, capacity, BufferUsage.WriteOnly);
}
private static VertexPositionColorTexture Vertex(Vector2 position, Color color, float u, float v) =>
new(new Vector3(position, 0f), color, new Vector2(u, v));
private static IndexBuffer CreateQuadIndexBuffer(GraphicsDevice device)
{
var indices = new ushort[MaxQuadsPerDraw * 6];
for (var quad = 0; quad < MaxQuadsPerDraw; quad++)
{
var vertex = quad * 4;
var index = quad * 6;
indices[index + 0] = (ushort)(vertex + 0);
indices[index + 1] = (ushort)(vertex + 1);
indices[index + 2] = (ushort)(vertex + 2);
indices[index + 3] = (ushort)(vertex + 2);
indices[index + 4] = (ushort)(vertex + 1);
indices[index + 5] = (ushort)(vertex + 3);
}
var buffer = new IndexBuffer(device, IndexElementSize.SixteenBits, indices.Length, BufferUsage.WriteOnly);
buffer.SetData(indices);
return buffer;
}
}
@@ -0,0 +1,20 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace MrGameEng.Graphics;
/// <summary>Configuration of <see cref="Renderer2D"/>.</summary>
public sealed class Renderer2DOptions
{
/// <summary>
/// Fixed virtual resolution. When set, the world is rendered at this resolution and
/// letterbox-scaled to the window. When null, the backbuffer size is used directly.
/// </summary>
public Point? VirtualResolution { get; set; }
/// <summary>Texture sampling. Defaults to <see cref="SamplerState.PointClamp"/> (crisp pixel art).</summary>
public SamplerState Sampler { get; set; } = SamplerState.PointClamp;
/// <summary>Initial sprite capacity of the batcher; grows automatically.</summary>
public int InitialCapacity { get; set; } = 2048;
}
@@ -0,0 +1,40 @@
using Friflo.Engine.ECS.Systems;
using MrGameEng.Core;
namespace MrGameEng.Graphics;
/// <summary>Wires the graphics module into a <see cref="Scene"/>.</summary>
public static class SceneGraphicsExtensions
{
/// <summary>
/// Attaches the 2D renderer to the scene: registers <see cref="CameraSystem"/>,
/// <see cref="SpriteRenderSystem"/>, any <paramref name="extraDrawSystems"/> and finally
/// <see cref="RenderFlushSystem"/> in the draw phase. The <see cref="Renderer2D"/> service
/// is created on first use and shared between scenes. Call from <c>OnLoad</c>.
/// </summary>
public static Renderer2D UseRenderer2D(
this Scene scene, Renderer2DOptions? options = null, params BaseSystem[] extraDrawSystems)
{
var services = scene.Context.Services;
var renderer = services.GetOrDefault<Renderer2D>();
if (renderer is null)
{
renderer = new Renderer2D(scene.Context.GraphicsDevice, options);
services.Add(renderer);
}
scene.DrawSystems.Add(new CameraSystem(renderer));
scene.DrawSystems.Add(new SpriteRenderSystem(renderer));
foreach (var system in extraDrawSystems)
{
scene.DrawSystems.Add(system);
}
scene.DrawSystems.Add(new RenderFlushSystem(renderer));
return renderer;
}
/// <summary>Adds <see cref="SpriteAnimationSystem"/> to the scene's update phase. Call from <c>OnLoad</c>.</summary>
public static void UseSpriteAnimation(this Scene scene) =>
scene.UpdateSystems.Add(new SpriteAnimationSystem());
}
+66
View File
@@ -0,0 +1,66 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
namespace MrGameEng.Graphics;
/// <summary>Horizontal / vertical mirroring of a sprite.</summary>
[Flags]
public enum SpriteFlip : byte
{
/// <summary>No mirroring.</summary>
None = 0,
/// <summary>Mirror horizontally.</summary>
X = 1,
/// <summary>Mirror vertically.</summary>
Y = 2,
}
/// <summary>
/// Sprite component: a texture region plus tint, origin, layer and depth.
/// Create via the constructor — the struct default has no region and a transparent tint.
/// </summary>
public struct Sprite : IComponent
{
/// <summary>The texture region to draw.</summary>
public Texture2DRegion? Region;
/// <summary>Tint color, multiplied with the texture. White = unmodified.</summary>
public Color Color;
/// <summary>
/// Pivot in region pixels, measured from the region's top-left corner. The sprite is
/// positioned, rotated and scaled around this point.
/// </summary>
public Vector2 Origin;
/// <summary>The render layer this sprite belongs to.</summary>
public LayerId Layer;
/// <summary>Draw order within the layer (smaller = drawn first / behind). Ignored on Y-sort layers.</summary>
public float Depth;
/// <summary>Mirroring flags.</summary>
public SpriteFlip Flip;
/// <summary>Creates a sprite on the given layer with a white tint and top-left origin.</summary>
public Sprite(Texture2DRegion region, LayerId layer = default)
{
Region = region;
Color = Color.White;
Origin = Vector2.Zero;
Layer = layer;
Depth = 0f;
Flip = SpriteFlip.None;
}
/// <summary>Sets <see cref="Origin"/> to the center of the region.</summary>
public void CenterOrigin()
{
if (Region is not null)
{
Origin = new Vector2(Region.Width / 2f, Region.Height / 2f);
}
}
}
+125
View File
@@ -0,0 +1,125 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
namespace MrGameEng.Graphics;
/// <summary>A frame-by-frame sprite animation: an ordered list of texture regions played at a fixed rate.</summary>
public sealed class SpriteAnimationClip
{
/// <summary>Animation frames in play order. Never empty.</summary>
public IReadOnlyList<Texture2DRegion> Frames { get; }
/// <summary>Playback rate in frames per second.</summary>
public float FramesPerSecond { get; }
/// <summary>Restart from the first frame after the last one.</summary>
public bool Loop { get; }
/// <summary>Total clip duration in seconds.</summary>
public float Duration => Frames.Count / FramesPerSecond;
/// <summary>Creates a clip.</summary>
public SpriteAnimationClip(IReadOnlyList<Texture2DRegion> frames, float framesPerSecond = 12f, bool loop = true)
{
if (frames.Count == 0)
{
throw new ArgumentException("An animation clip needs at least one frame.", nameof(frames));
}
Frames = frames;
FramesPerSecond = framesPerSecond;
Loop = loop;
}
/// <summary>Returns the frame shown at <paramref name="time"/> seconds into the clip.</summary>
public Texture2DRegion FrameAt(float time)
{
var frame = (int)(time * FramesPerSecond);
if (Loop)
{
frame = ((frame % Frames.Count) + Frames.Count) % Frames.Count;
}
else
{
frame = Math.Clamp(frame, 0, Frames.Count - 1);
}
return Frames[frame];
}
}
/// <summary>
/// Plays a <see cref="SpriteAnimationClip"/> on the entity's <see cref="Sprite"/>.
/// Create via the constructor — the struct default has no clip and zero speed.
/// </summary>
public struct SpriteAnimator : IComponent
{
/// <summary>The clip being played; null = nothing to play.</summary>
public SpriteAnimationClip? Clip;
/// <summary>Playback position in seconds.</summary>
public float Time;
/// <summary>Playback speed multiplier. 1 = normal.</summary>
public float Speed;
/// <summary>False pauses playback.</summary>
public bool Playing;
/// <summary>Starts playing <paramref name="clip"/> from the beginning.</summary>
public SpriteAnimator(SpriteAnimationClip clip)
{
Clip = clip;
Time = 0f;
Speed = 1f;
Playing = true;
}
/// <summary>Switches to <paramref name="clip"/> and restarts unless it is already playing.</summary>
public void Play(SpriteAnimationClip clip)
{
if (ReferenceEquals(Clip, clip) && Playing)
{
return;
}
Clip = clip;
Time = 0f;
Playing = true;
}
}
/// <summary>
/// Update-phase system advancing all <see cref="SpriteAnimator"/>s and writing the current
/// frame into the entity's <see cref="Sprite.Region"/>.
/// </summary>
public sealed class SpriteAnimationSystem : QuerySystem<Sprite, SpriteAnimator>
{
/// <inheritdoc />
protected override void OnUpdate()
{
var delta = Tick.deltaTime;
foreach (var (sprites, animators, _) in Query.Chunks)
{
var s = sprites.Span;
var a = animators.Span;
for (var i = 0; i < s.Length; i++)
{
ref var animator = ref a[i];
if (!animator.Playing || animator.Clip is not { } clip)
{
continue;
}
animator.Time += delta * animator.Speed;
if (!clip.Loop && animator.Time >= clip.Duration)
{
animator.Time = clip.Duration;
animator.Playing = false;
}
s[i].Region = clip.FrameAt(animator.Time);
}
}
}
}
+94
View File
@@ -0,0 +1,94 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Graphics;
/// <summary>One sprite queued for rendering this frame.</summary>
public struct SpriteInstance
{
/// <summary>Texture region to draw. Never null for submitted instances.</summary>
public Texture2DRegion Region;
/// <summary>World-space (or screen-space) center of the quad.</summary>
public Vector2 Center;
/// <summary>Half extents after scaling, in pixels. May be negative for negative scale.</summary>
public Vector2 HalfSize;
/// <summary>Rotation in radians, clockwise.</summary>
public float Rotation;
/// <summary>Tint color.</summary>
public Color Color;
/// <summary>Mirroring flags.</summary>
public SpriteFlip Flip;
/// <summary>Render layer the instance belongs to.</summary>
public byte Layer;
}
/// <summary>
/// CPU side of the renderer: collects <see cref="SpriteInstance"/>s with their sort keys
/// and orders them layer → depth → texture. Allocation-free after warm-up
/// (arrays grow geometrically and are reused across frames).
/// </summary>
public sealed class SpriteBatcher
{
private SpriteInstance[] _instances;
private ulong[] _keys;
private int[] _order;
private int _count;
/// <summary>Creates a batcher with the given initial capacity.</summary>
public SpriteBatcher(int initialCapacity = 2048)
{
_instances = new SpriteInstance[initialCapacity];
_keys = new ulong[initialCapacity];
_order = new int[initialCapacity];
}
/// <summary>Number of sprites submitted this frame.</summary>
public int Count => _count;
/// <summary>Queues one sprite.</summary>
public void Submit(in SpriteInstance instance, ulong sortKey)
{
if (_count == _instances.Length)
{
Grow();
}
_instances[_count] = instance;
_keys[_count] = sortKey;
_count++;
}
/// <summary>
/// Sorts all submitted sprites and returns their indices in draw order.
/// Valid until the next <see cref="Clear"/>.
/// </summary>
public ReadOnlySpan<int> Sort()
{
for (var i = 0; i < _count; i++)
{
_order[i] = i;
}
Array.Sort(_keys, _order, 0, _count);
return _order.AsSpan(0, _count);
}
/// <summary>Returns the instance at <paramref name="index"/> (an index from <see cref="Sort"/>).</summary>
public ref readonly SpriteInstance this[int index] => ref _instances[index];
/// <summary>Resets the batcher for the next frame. Keeps allocated capacity.</summary>
public void Clear() => _count = 0;
private void Grow()
{
var capacity = _instances.Length * 2;
Array.Resize(ref _instances, capacity);
Array.Resize(ref _keys, capacity);
Array.Resize(ref _order, capacity);
}
}
+23
View File
@@ -0,0 +1,23 @@
namespace MrGameEng.Graphics;
/// <summary>
/// Builds the 64-bit sort key the batcher orders sprites by:
/// layer (8 bits) → depth (32 bits) → texture (24 bits).
/// Texture bits only group equal textures for batching; collisions are harmless.
/// </summary>
public static class SpriteSortKey
{
/// <summary>Composes a sort key from layer, depth and texture grouping key.</summary>
public static ulong Make(byte layer, float depth, int textureKey) =>
((ulong)layer << 56) | ((ulong)DepthToSortableBits(depth) << 24) | ((uint)textureKey & 0xFF_FFFF);
/// <summary>
/// Maps a float to bits whose unsigned order matches the float order
/// (negative depths sort before positive ones).
/// </summary>
public static uint DepthToSortableBits(float depth)
{
var bits = BitConverter.SingleToUInt32Bits(depth);
return (bits & 0x8000_0000) != 0 ? ~bits : bits | 0x8000_0000;
}
}
+40
View File
@@ -0,0 +1,40 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace MrGameEng.Graphics;
/// <summary>
/// A rectangular region of a texture — the unit sprites are drawn from. A standalone texture
/// is a region covering the whole texture; texture atlases hand out one region per sprite,
/// and sprites sharing an atlas batch into a single draw call automatically.
/// </summary>
public sealed class Texture2DRegion
{
/// <summary>The texture this region belongs to.</summary>
public Texture2D Texture { get; }
/// <summary>Region bounds in texture pixels.</summary>
public Rectangle Bounds { get; }
/// <summary>Region width in pixels.</summary>
public int Width => Bounds.Width;
/// <summary>Region height in pixels.</summary>
public int Height => Bounds.Height;
internal readonly int TextureSortKey;
/// <summary>Creates a region covering part of <paramref name="texture"/>.</summary>
public Texture2DRegion(Texture2D texture, Rectangle bounds)
{
Texture = texture;
Bounds = bounds;
TextureSortKey = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(texture);
}
/// <summary>Creates a region covering the whole <paramref name="texture"/>.</summary>
public Texture2DRegion(Texture2D texture)
: this(texture, new Rectangle(0, 0, texture.Width, texture.Height))
{
}
}
+32
View File
@@ -0,0 +1,32 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
namespace MrGameEng.Graphics;
/// <summary>
/// 2D transform component: position (world units = pixels), rotation (radians, clockwise
/// in the engine's y-down coordinate system) and per-axis scale.
/// Create via <see cref="At"/> or the constructor — the struct default has zero scale.
/// </summary>
public struct Transform2D : IComponent
{
/// <summary>World position in pixels.</summary>
public Vector2 Position;
/// <summary>Rotation in radians, clockwise (y-down).</summary>
public float Rotation;
/// <summary>Per-axis scale. 1 is unscaled.</summary>
public Vector2 Scale;
/// <summary>Creates a transform with the given position, rotation and scale.</summary>
public Transform2D(Vector2 position, float rotation = 0f, Vector2? scale = null)
{
Position = position;
Rotation = rotation;
Scale = scale ?? Vector2.One;
}
/// <summary>Creates an unrotated, unscaled transform at <paramref name="position"/>.</summary>
public static Transform2D At(Vector2 position) => new(position);
}
+86
View File
@@ -0,0 +1,86 @@
using Microsoft.Xna.Framework.Input;
namespace MrGameEng.Input;
/// <summary>
/// Maps game actions (an enum) to any number of physical bindings: keys, mouse buttons or
/// gamepad buttons. Query by action instead of device, rebind at runtime.
/// </summary>
/// <typeparam name="TAction">Enum (or any value) identifying the game's actions.</typeparam>
public sealed class ActionMap<TAction> where TAction : notnull
{
private readonly InputManager _input;
private readonly Dictionary<TAction, List<Binding>> _bindings = new();
private readonly record struct Binding(Keys? Key, MouseButton? Mouse, Buttons? GamePad);
/// <summary>Creates an action map querying <paramref name="input"/>.</summary>
public ActionMap(InputManager input) => _input = input;
/// <summary>Adds a keyboard binding for <paramref name="action"/>.</summary>
public ActionMap<TAction> Bind(TAction action, Keys key) => Add(action, new Binding(key, null, null));
/// <summary>Adds a mouse-button binding for <paramref name="action"/>.</summary>
public ActionMap<TAction> Bind(TAction action, MouseButton button) => Add(action, new Binding(null, button, null));
/// <summary>Adds a gamepad-button binding for <paramref name="action"/>.</summary>
public ActionMap<TAction> Bind(TAction action, Buttons button) => Add(action, new Binding(null, null, button));
/// <summary>Removes every binding of <paramref name="action"/> (for rebinding).</summary>
public void Unbind(TAction action) => _bindings.Remove(action);
/// <summary>True while any binding of the action is held down.</summary>
public bool IsDown(TAction action) => Any(action,
static (input, b) =>
(b.Key is { } k && input.IsKeyDown(k)) ||
(b.Mouse is { } m && input.IsMouseDown(m)) ||
(b.GamePad is { } g && input.IsButtonDown(g)));
/// <summary>True only on the frame any binding of the action went down.</summary>
public bool IsPressed(TAction action) => Any(action,
static (input, b) =>
(b.Key is { } k && input.IsKeyPressed(k)) ||
(b.Mouse is { } m && input.IsMousePressed(m)) ||
(b.GamePad is { } g && input.IsButtonPressed(g)));
/// <summary>True only on the frame any binding of the action went up.</summary>
public bool IsReleased(TAction action) => Any(action,
static (input, b) =>
(b.Key is { } k && input.IsKeyReleased(k)) ||
(b.Mouse is { } m && input.IsMouseReleased(m)) ||
(b.GamePad is { } g && input.IsButtonReleased(g)));
/// <summary>Composes -1/0/+1 from two digital actions (e.g. move left / move right).</summary>
public float GetAxis(TAction negative, TAction positive) =>
(IsDown(positive) ? 1f : 0f) - (IsDown(negative) ? 1f : 0f);
private ActionMap<TAction> Add(TAction action, Binding binding)
{
if (!_bindings.TryGetValue(action, out var list))
{
list = [];
_bindings.Add(action, list);
}
list.Add(binding);
return this;
}
private bool Any(TAction action, Func<InputManager, Binding, bool> predicate)
{
if (!_bindings.TryGetValue(action, out var list))
{
return false;
}
foreach (var binding in list)
{
if (predicate(_input, binding))
{
return true;
}
}
return false;
}
}
+98
View File
@@ -0,0 +1,98 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
namespace MrGameEng.Input;
/// <summary>Mouse buttons addressable through <see cref="InputManager"/>.</summary>
public enum MouseButton
{
/// <summary>Left button.</summary>
Left,
/// <summary>Right button.</summary>
Right,
/// <summary>Middle button (wheel click).</summary>
Middle,
}
/// <summary>
/// Polls keyboard, mouse and gamepad once per frame and keeps the previous frame's state,
/// enabling edge queries (<c>Pressed</c> = went down this frame, <c>Released</c> = went up).
/// Registered as a service by <c>scene.UseInput()</c>; polled by <see cref="InputSystem"/>
/// at the start of the update phase.
/// </summary>
public sealed class InputManager
{
private KeyboardState _keyboard;
private KeyboardState _previousKeyboard;
private MouseState _mouse;
private MouseState _previousMouse;
private GamePadState _gamePad;
private GamePadState _previousGamePad;
/// <summary>Polls all devices. Called once per frame by <see cref="InputSystem"/>.</summary>
public void Update() => Apply(
Keyboard.GetState(),
Mouse.GetState(),
GamePad.GetState(PlayerIndex.One));
internal void Apply(KeyboardState keyboard, MouseState mouse, GamePadState gamePad)
{
_previousKeyboard = _keyboard;
_previousMouse = _mouse;
_previousGamePad = _gamePad;
_keyboard = keyboard;
_mouse = mouse;
_gamePad = gamePad;
}
/// <summary>True while the key is held down.</summary>
public bool IsKeyDown(Keys key) => _keyboard.IsKeyDown(key);
/// <summary>True only on the frame the key went down.</summary>
public bool IsKeyPressed(Keys key) => _keyboard.IsKeyDown(key) && _previousKeyboard.IsKeyUp(key);
/// <summary>True only on the frame the key went up.</summary>
public bool IsKeyReleased(Keys key) => _keyboard.IsKeyUp(key) && _previousKeyboard.IsKeyDown(key);
/// <summary>Mouse cursor position in window pixels.</summary>
public Point MousePosition => _mouse.Position;
/// <summary>Cursor movement since the previous frame.</summary>
public Point MouseDelta => _mouse.Position - _previousMouse.Position;
/// <summary>Scroll wheel change since the previous frame (positive = up).</summary>
public int WheelDelta => _mouse.ScrollWheelValue - _previousMouse.ScrollWheelValue;
/// <summary>True while the mouse button is held down.</summary>
public bool IsMouseDown(MouseButton button) => GetButton(_mouse, button) == ButtonState.Pressed;
/// <summary>True only on the frame the mouse button went down.</summary>
public bool IsMousePressed(MouseButton button) =>
GetButton(_mouse, button) == ButtonState.Pressed && GetButton(_previousMouse, button) == ButtonState.Released;
/// <summary>True only on the frame the mouse button went up.</summary>
public bool IsMouseReleased(MouseButton button) =>
GetButton(_mouse, button) == ButtonState.Released && GetButton(_previousMouse, button) == ButtonState.Pressed;
/// <summary>True while the gamepad button is held down.</summary>
public bool IsButtonDown(Buttons button) => _gamePad.IsButtonDown(button);
/// <summary>True only on the frame the gamepad button went down.</summary>
public bool IsButtonPressed(Buttons button) => _gamePad.IsButtonDown(button) && _previousGamePad.IsButtonUp(button);
/// <summary>True only on the frame the gamepad button went up.</summary>
public bool IsButtonReleased(Buttons button) => _gamePad.IsButtonUp(button) && _previousGamePad.IsButtonDown(button);
/// <summary>Left thumbstick, x/y in [-1, 1]. Y is inverted to match the engine's y-down world.</summary>
public Vector2 LeftStick => new(_gamePad.ThumbSticks.Left.X, -_gamePad.ThumbSticks.Left.Y);
private static ButtonState GetButton(in MouseState state, MouseButton button) => button switch
{
MouseButton.Left => state.LeftButton,
MouseButton.Right => state.RightButton,
MouseButton.Middle => state.MiddleButton,
_ => ButtonState.Released,
};
}
+39
View File
@@ -0,0 +1,39 @@
using Friflo.Engine.ECS.Systems;
using MrGameEng.Core;
namespace MrGameEng.Input;
/// <summary>Polls the <see cref="InputManager"/> once per frame. Registered first in the update phase.</summary>
public sealed class InputSystem : BaseSystem
{
private readonly InputManager _input;
/// <summary>Creates the system for <paramref name="input"/>.</summary>
public InputSystem(InputManager input) => _input = input;
/// <inheritdoc />
protected override void OnUpdateGroup() => _input.Update();
}
/// <summary>Wires the input module into a <see cref="Scene"/>.</summary>
public static class SceneInputExtensions
{
/// <summary>
/// Returns the shared <see cref="InputManager"/> service (creating it on first use) and
/// inserts <see cref="InputSystem"/> at the start of the scene's update phase.
/// Call from <c>OnLoad</c> before adding gameplay systems.
/// </summary>
public static InputManager UseInput(this Scene scene)
{
var services = scene.Context.Services;
var input = services.GetOrDefault<InputManager>();
if (input is null)
{
input = new InputManager();
services.Add(input);
}
scene.UpdateSystems.Insert(0, new InputSystem(input));
return input;
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Input.Tests" />
</ItemGroup>
</Project>