diff --git a/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs b/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs
index 6cfc7ba..3c41adc 100644
--- a/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs
+++ b/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs
@@ -1,6 +1,7 @@
using System.Collections.Immutable;
using System.Text;
using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
namespace MrGameEng.Assets.Generator;
@@ -40,8 +41,15 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
ClassName: string.IsNullOrEmpty(className) ? "GameAssets" : className!);
});
+ var projectDir = context.AnalyzerConfigOptionsProvider.Select(static (provider, _) =>
+ {
+ provider.GlobalOptions.TryGetValue("build_property.projectdir", out var dir);
+ return dir ?? string.Empty;
+ });
+
var assets = context.AdditionalTextsProvider
- .Select(static (text, _) => ToAssetPath(text.Path))
+ .Combine(projectDir)
+ .Select(static (pair, _) => ToAssetPath(pair.Left.Path, pair.Right))
.Where(static path => path is not null)
.Collect();
@@ -51,18 +59,31 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
///
/// 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.
+ /// the file is outside an Assets directory or has an unknown extension. With a known
+ /// the marker is the project's root Assets/ folder,
+ /// so nested directories that happen to be called "Assets" do not shift the root;
+ /// without one, the last /Assets/ segment of the path is used.
///
- internal static string? ToAssetPath(string fullPath)
+ internal static string? ToAssetPath(string fullPath, string? projectDir = null)
{
var normalized = fullPath.Replace('\\', '/');
- var marker = normalized.LastIndexOf("/Assets/", StringComparison.OrdinalIgnoreCase);
- if (marker < 0)
- {
- return null;
- }
- var relative = normalized.Substring(marker + "/Assets/".Length);
+ string relative;
+ var root = string.IsNullOrEmpty(projectDir) ? null : projectDir!.Replace('\\', '/').TrimEnd('/');
+ if (root is not null && normalized.StartsWith(root + "/Assets/", StringComparison.OrdinalIgnoreCase))
+ {
+ relative = normalized.Substring(root.Length + "/Assets/".Length);
+ }
+ else
+ {
+ var marker = normalized.LastIndexOf("/Assets/", StringComparison.OrdinalIgnoreCase);
+ if (marker < 0)
+ {
+ return null;
+ }
+
+ relative = normalized.Substring(marker + "/Assets/".Length);
+ }
// Страницы атласов (Name.atlas.0.png) — внутренние файлы метаданных .atlas,
// им собственные Texture2D-хендлы не нужны.
@@ -97,36 +118,41 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
source.AppendLine("/// Typed handles for every file under the Assets directory.");
source.AppendLine($"public static partial class {className}");
source.AppendLine("{");
- EmitNode(source, root, indent: 1);
+ EmitNode(source, root, indent: 1, enclosingName: className);
source.AppendLine("}");
return source.ToString();
}
- private static void EmitNode(StringBuilder source, Node node, int indent)
+ private static void EmitNode(StringBuilder source, Node node, int indent, string enclosingName)
{
var pad = new string(' ', indent * 4);
- var usedNames = new HashSet();
+ // Имя вмещающего класса занято: член с тем же именем — ошибка CS0542.
+ var usedNames = new HashSet { enclosingName };
foreach (var (fileName, relativePath) in node.Files)
{
var type = TypeByExtension[Path.GetExtension(fileName)];
var name = Unique(usedNames, Identifier(Path.GetFileNameWithoutExtension(fileName)));
- source.AppendLine($"{pad}/// {relativePath}");
+ source.AppendLine($"{pad}/// {XmlEscape(relativePath)}");
source.AppendLine(
- $"{pad}public static readonly global::MrGameEng.Assets.AssetRef<{type}> {name} = new(\"{relativePath}\");");
+ $"{pad}public static readonly global::MrGameEng.Assets.AssetRef<{type}> {name} = " +
+ $"new({SymbolDisplay.FormatLiteral(relativePath, quote: true)});");
}
foreach (var pair in node.Children)
{
var name = Unique(usedNames, Identifier(pair.Key));
- source.AppendLine($"{pad}/// {pair.Key}/");
+ source.AppendLine($"{pad}/// {XmlEscape(pair.Key)}/");
source.AppendLine($"{pad}public static class {name}");
source.AppendLine($"{pad}{{");
- EmitNode(source, pair.Value, indent + 1);
+ EmitNode(source, pair.Value, indent + 1, enclosingName: name);
source.AppendLine($"{pad}}}");
}
}
+ private static string XmlEscape(string text) =>
+ text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
+
/// Converts an arbitrary file or directory name to a PascalCase C# identifier.
internal static string Identifier(string name)
{
diff --git a/src/MrGameEng.Atlases/AtlasBuilder.cs b/src/MrGameEng.Atlases/AtlasBuilder.cs
index 89880e2..656e36d 100644
--- a/src/MrGameEng.Atlases/AtlasBuilder.cs
+++ b/src/MrGameEng.Atlases/AtlasBuilder.cs
@@ -54,6 +54,9 @@ public static class AtlasBuilder
{
private static readonly string[] SourceExtensions = [".png", ".jpg", ".jpeg", ".bmp"];
+ /// Snapshot of one source image taken at scan time (size/mtime feed the staleness check).
+ private readonly record struct SourceFile(string FullPath, string Key, long Size, long ModifiedTicks);
+
/// Builds (or incrementally refreshes) all atlases for .
public static AtlasBuildResult Build(AtlasBuildOptions options)
{
@@ -87,10 +90,10 @@ public static class AtlasBuilder
return (name, key);
}
- private static SortedDictionary> ScanGroups(
+ private static SortedDictionary> ScanGroups(
string sourceRoot, AtlasBuildOptions options)
{
- var groups = new SortedDictionary>(StringComparer.Ordinal);
+ var groups = new SortedDictionary>(StringComparer.Ordinal);
var keys = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var fullPath in Directory.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories))
{
@@ -113,14 +116,15 @@ public static class AtlasBuilder
groups.Add(atlasName, list);
}
- list.Add((fullPath, key));
+ var info = new FileInfo(fullPath);
+ list.Add(new SourceFile(fullPath, key, info.Length, info.LastWriteTimeUtc.Ticks));
}
return groups;
}
private static AtlasGroupResult BuildGroup(
- string name, List<(string FullPath, string Key)> files, AtlasBuildOptions options)
+ string name, List files, AtlasBuildOptions options)
{
var metadataPath = Path.Combine(options.OutputDirectory, name + ".atlas");
if (!options.Force && IsUpToDate(metadataPath, files, options, out var existingPages))
@@ -151,14 +155,14 @@ public static class AtlasBuilder
}
WritePages(name, packed, pixelsByKey, options.OutputDirectory);
- WriteMetadata(name, packed, options, metadataPath);
+ WriteMetadata(name, packed, files, options, metadataPath);
DeleteExtraPages(name, packed.PageSizes.Count, options.OutputDirectory);
return new AtlasGroupResult(name, files.Count, packed.PageSizes.Count, Skipped: false);
}
private static bool IsUpToDate(
- string metadataPath, List<(string FullPath, string Key)> files, AtlasBuildOptions options, out int pages)
+ string metadataPath, List files, AtlasBuildOptions options, out int pages)
{
pages = 0;
if (!File.Exists(metadataPath))
@@ -176,7 +180,8 @@ public static class AtlasBuilder
return false;
}
- if (metadata.PageSize != options.MaxPageSize || metadata.Padding != options.Padding)
+ if (metadata.Version != AtlasMetadata.CurrentVersion ||
+ metadata.PageSize != options.MaxPageSize || metadata.Padding != options.Padding)
{
return false;
}
@@ -187,16 +192,21 @@ public static class AtlasBuilder
return false;
}
- if (!metadata.Regions.Select(r => r.Key).Order(StringComparer.Ordinal)
- .SequenceEqual(files.Select(f => f.Key).Order(StringComparer.Ordinal)))
+ // Источники сравниваются по точному снапшоту (ключ + размер + mtime), а не по
+ // «новее метаданных»: переименования и копии с сохранением времени тоже ловятся.
+ if (metadata.Sources.Count != files.Count)
{
return false;
}
- var builtAt = File.GetLastWriteTimeUtc(metadataPath);
- if (files.Any(f => File.GetLastWriteTimeUtc(f.FullPath) > builtAt))
+ var sourcesByKey = metadata.Sources.ToDictionary(s => s.Key, StringComparer.Ordinal);
+ foreach (var file in files)
{
- return false;
+ if (!sourcesByKey.TryGetValue(file.Key, out var source) ||
+ source.Size != file.Size || source.Modified != file.ModifiedTicks)
+ {
+ return false;
+ }
}
pages = metadata.Pages.Count;
@@ -233,13 +243,18 @@ public static class AtlasBuilder
});
}
- private static void WriteMetadata(string name, PackResult packed, AtlasBuildOptions options, string metadataPath)
+ private static void WriteMetadata(
+ string name, PackResult packed, List files, AtlasBuildOptions options, string metadataPath)
{
var metadata = new AtlasMetadata
{
Name = name,
PageSize = options.MaxPageSize,
Padding = options.Padding,
+ Sources = files
+ .OrderBy(f => f.Key, StringComparer.Ordinal)
+ .Select(f => new AtlasSource { Key = f.Key, Size = f.Size, Modified = f.ModifiedTicks })
+ .ToList(),
Pages = packed.PageSizes
.Select((size, index) => new AtlasPage
{
diff --git a/src/MrGameEng.Atlases/AtlasMetadata.cs b/src/MrGameEng.Atlases/AtlasMetadata.cs
index 171eeaa..ff2ae26 100644
--- a/src/MrGameEng.Atlases/AtlasMetadata.cs
+++ b/src/MrGameEng.Atlases/AtlasMetadata.cs
@@ -15,8 +15,11 @@ public sealed class AtlasMetadata
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
- /// Format version, bumped on breaking metadata changes.
- public int Version { get; init; } = 1;
+ /// Current format version. Bumped on breaking metadata changes.
+ public const int CurrentVersion = 2;
+
+ /// Format version of this file; readers reject other versions.
+ public int Version { get; init; } = CurrentVersion;
/// Atlas name (group key with '/' replaced by '.').
public string Name { get; init; } = "";
@@ -33,6 +36,9 @@ public sealed class AtlasMetadata
/// Packed regions, sorted by key.
public List Regions { get; init; } = [];
+ /// Source files the atlas was built from, sorted by key (staleness check input).
+ public List Sources { get; init; } = [];
+
/// Serializes this metadata to indented JSON.
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
@@ -55,6 +61,23 @@ public sealed class AtlasPage
public int Height { get; init; }
}
+///
+/// Snapshot of one source file at build time. The incremental rebuild compares key, size and
+/// modification time instead of relying on "source newer than metadata", so timestamp-preserving
+/// renames and copies still invalidate the atlas.
+///
+public sealed class AtlasSource
+{
+ /// Region key of the source file.
+ public string Key { get; init; } = "";
+
+ /// Source file size in bytes.
+ public long Size { get; init; }
+
+ /// Source file LastWriteTimeUtc in ticks.
+ public long Modified { get; init; }
+}
+
/// One packed source texture inside an atlas.
public sealed class AtlasRegion
{
diff --git a/src/MrGameEng.Atlases/ShelfPacker.cs b/src/MrGameEng.Atlases/ShelfPacker.cs
index b926be6..e3f45b1 100644
--- a/src/MrGameEng.Atlases/ShelfPacker.cs
+++ b/src/MrGameEng.Atlases/ShelfPacker.cs
@@ -17,7 +17,10 @@ public readonly record struct PackPlacement(string Key, int Page, int X, int Y,
/// Result of a packing run: placements plus the trimmed size of every page.
/// One placement per input item.
-/// Width/height of each page, trimmed to the next power of two covering its content.
+///
+/// Width/height of each page: the next power of two covering its content, clamped to the
+/// page-size limit. Dedicated pages of oversized items keep their exact (padded) size.
+///
public sealed record PackResult(IReadOnlyList Placements, IReadOnlyList<(int Width, int Height)> PageSizes);
///
@@ -65,7 +68,9 @@ public static class ShelfPacker
{
if (open)
{
- pageSizes.Add((NextPowerOfTwo(usedWidth + padding), NextPowerOfTwo(usedHeight + padding)));
+ pageSizes.Add((
+ PageDimension(usedWidth + padding, maxPageSize),
+ PageDimension(usedHeight + padding, maxPageSize)));
open = false;
}
}
@@ -81,15 +86,22 @@ public static class ShelfPacker
usedHeight = 0;
}
+ // Негабаритные — первыми, на отдельные страницы точно под себя: посреди потока
+ // они закрывали бы наполовину заполненную общую страницу (потеря occupancy).
foreach (var item in sorted)
{
- // Слишком большой для общей страницы — отдельная страница точно под него.
if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
{
- CloseOpenPage();
placements.Add(new PackPlacement(item.Key, pageSizes.Count, padding, padding, item.Width, item.Height));
- pageSizes.Add((NextPowerOfTwo(item.Width + 2 * padding), NextPowerOfTwo(item.Height + 2 * padding)));
- continue;
+ pageSizes.Add((item.Width + 2 * padding, item.Height + 2 * padding));
+ }
+ }
+
+ foreach (var item in sorted)
+ {
+ if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
+ {
+ continue; // уже размещён на отдельной странице
}
if (!open)
@@ -119,6 +131,11 @@ public static class ShelfPacker
return new PackResult(placements, pageSizes);
}
+ // POT удобен GPU, но страница не должна превышать заявленный лимит,
+ // когда maxPageSize сам не степень двойки.
+ private static int PageDimension(int used, int maxPageSize) =>
+ Math.Min(NextPowerOfTwo(used), maxPageSize);
+
internal static int NextPowerOfTwo(int value)
{
var result = 1;
diff --git a/src/MrGameEng.Atlases/TextureAtlas.cs b/src/MrGameEng.Atlases/TextureAtlas.cs
index 3748203..d7b6e89 100644
--- a/src/MrGameEng.Atlases/TextureAtlas.cs
+++ b/src/MrGameEng.Atlases/TextureAtlas.cs
@@ -56,12 +56,31 @@ public sealed class TextureAtlas : IDisposable
public static TextureAtlas Load(GraphicsDevice graphicsDevice, string metadataPath)
{
var metadata = AtlasMetadata.FromJson(File.ReadAllText(metadataPath));
+ if (metadata.Version != AtlasMetadata.CurrentVersion)
+ {
+ throw new InvalidDataException(
+ $"Atlas '{metadataPath}' has format version {metadata.Version}, expected " +
+ $"{AtlasMetadata.CurrentVersion}. Rebuild the atlases with the atlas tool.");
+ }
+
var directory = Path.GetDirectoryName(Path.GetFullPath(metadataPath))!;
var pages = new Texture2D[metadata.Pages.Count];
- for (var i = 0; i < pages.Length; i++)
+ try
{
- using var stream = File.OpenRead(Path.Combine(directory, metadata.Pages[i].File));
- pages[i] = Texture2D.FromStream(graphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha);
+ for (var i = 0; i < pages.Length; i++)
+ {
+ using var stream = File.OpenRead(Path.Combine(directory, metadata.Pages[i].File));
+ pages[i] = Texture2D.FromStream(graphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha);
+ }
+ }
+ catch
+ {
+ foreach (var page in pages)
+ {
+ page?.Dispose(); // частично загруженные страницы не должны утекать
+ }
+
+ throw;
}
return new TextureAtlas(metadata, pages);
diff --git a/src/MrGameEng.Audio/MusicPlayer.cs b/src/MrGameEng.Audio/MusicPlayer.cs
index 1e393f6..0238876 100644
--- a/src/MrGameEng.Audio/MusicPlayer.cs
+++ b/src/MrGameEng.Audio/MusicPlayer.cs
@@ -40,9 +40,25 @@ public sealed class MusicPlayer : IDisposable
/// Starts streaming , stopping the previous one.
public void Play(MusicTrack track, bool loop = true)
{
+ // Валидация до Stop(): негодный файл не должен обрывать играющий трек.
+ var reader = new VorbisReader(track.FullPath);
+ if (reader.Channels is < 1 or > 2)
+ {
+ reader.Dispose();
+ throw new NotSupportedException(
+ $"Music '{track.FullPath}' has {reader.Channels} channels; only mono and stereo are supported.");
+ }
+
+ if (reader.SampleRate is < 8000 or > 48000)
+ {
+ reader.Dispose();
+ throw new NotSupportedException(
+ $"Music '{track.FullPath}' has sample rate {reader.SampleRate} Hz; supported range is 8000–48000 Hz.");
+ }
+
Stop();
_loop = loop;
- _reader = new VorbisReader(track.FullPath);
+ _reader = reader;
// ~0.5 seconds of samples per submitted buffer.
var samplesPerBuffer = _reader.SampleRate * _reader.Channels / 2;
@@ -91,13 +107,22 @@ public sealed class MusicPlayer : IDisposable
var read = _reader.ReadSamples(_sampleBuffer, 0, _sampleBuffer.Length);
if (read == 0)
{
- if (!_loop)
+ // SamplePosition > 0 отличает конец трека от пустого файла: после перемотки
+ // на 0 повторный read == 0 не зацикливается, а завершает воспроизведение.
+ if (_loop && _reader.SamplePosition > 0)
{
- return;
+ _reader.SamplePosition = 0;
+ continue;
}
- _reader.SamplePosition = 0;
- continue;
+ // Конец незацикленного трека (или пустой файл): когда буферы доиграли,
+ // останавливаем инстанс — иначе IsPlaying остаётся true навсегда.
+ if (_instance.PendingBufferCount == 0)
+ {
+ _instance.Stop();
+ }
+
+ return;
}
for (var i = 0; i < read; i++)
diff --git a/src/MrGameEng.Collisions/CollisionWorld.cs b/src/MrGameEng.Collisions/CollisionWorld.cs
index e49bab9..5ff75a0 100644
--- a/src/MrGameEng.Collisions/CollisionWorld.cs
+++ b/src/MrGameEng.Collisions/CollisionWorld.cs
@@ -54,9 +54,14 @@ public sealed class CollisionWorld
}
_cellSize = cellSize;
+ Array.Fill(_bucketHeads, -1); // пустой хэш до первого ребилда: обход бакета сразу завершается
}
- /// Pairs found by the last rebuild.
+ ///
+ /// Pairs found by the last rebuild. Order is deterministic for identical registration
+ /// history; it may differ between sessions whose peak collider count differed
+ /// (the hash table only grows and its size affects bucket iteration order).
+ ///
public ReadOnlySpan Pairs => _pairs.AsSpan(0, _pairCount);
/// Colliders registered in the last rebuild.
@@ -134,6 +139,7 @@ public sealed class CollisionWorld
{
if (found == results.Length)
{
+ ResetQueryStamps(); // иначе следующий запрос пропустит помеченные записи
return found;
}
@@ -150,6 +156,8 @@ public sealed class CollisionWorld
///
/// Casts a segment and returns the closest hit among colliders whose
/// intersects .
+ /// A ray starting inside a collider hits it at fraction 0. Linear scan over all
+ /// registered colliders — fine for occasional rays, not for thousands per tick.
///
public bool Raycast(Vector2 from, Vector2 to, out RaycastHit hit, uint mask = uint.MaxValue)
{
@@ -280,7 +288,9 @@ public sealed class CollisionWorld
if (a.Shape == ColliderShape.Box && b.Shape == ColliderShape.Box)
{
- return a.Aabb.Intersects(b.Aabb);
+ // Включительно (касание = пара) — единообразно с кругами; RectF.Intersects строгий.
+ return a.Aabb.Left <= b.Aabb.Right && b.Aabb.Left <= a.Aabb.Right &&
+ a.Aabb.Top <= b.Aabb.Bottom && b.Aabb.Top <= a.Aabb.Bottom;
}
// circle vs box
@@ -305,6 +315,11 @@ public sealed class CollisionWorld
var b = 2f * Vector2.Dot(f, d);
var c = Vector2.Dot(f, f) - radius * radius;
+ if (c <= 0f)
+ {
+ return true; // старт внутри круга — попадание в точке старта (как и у AABB)
+ }
+
var discriminant = b * b - 4f * a * c;
if (discriminant < 0f)
{
@@ -313,11 +328,6 @@ public sealed class CollisionWorld
var sqrt = MathF.Sqrt(discriminant);
var t = (-b - sqrt) / (2f * a);
- if (t < 0f)
- {
- t = (-b + sqrt) / (2f * a); // старт внутри круга
- }
-
if (t < 0f || t > 1f)
{
return false;
diff --git a/src/MrGameEng.Core/EngineContext.cs b/src/MrGameEng.Core/EngineContext.cs
index e5f6c54..d871908 100644
--- a/src/MrGameEng.Core/EngineContext.cs
+++ b/src/MrGameEng.Core/EngineContext.cs
@@ -36,4 +36,16 @@ public sealed class EngineContext
}
internal void AttachGraphicsDevice(GraphicsDevice device) => _graphicsDevice = device;
+
+ ///
+ /// Disposes everything the context owns: registered services
+ /// and the transition renderer. (the host itself, also a
+ /// registered service) is skipped — it is being disposed by the caller already.
+ /// Called by .
+ ///
+ internal void DisposeOwnedResources(object except)
+ {
+ Scenes.DisposeRenderer();
+ Services.DisposeServices(except);
+ }
}
diff --git a/src/MrGameEng.Core/GameHost.cs b/src/MrGameEng.Core/GameHost.cs
index fe789b4..6f4566c 100644
--- a/src/MrGameEng.Core/GameHost.cs
+++ b/src/MrGameEng.Core/GameHost.cs
@@ -29,6 +29,9 @@ public class GameHost : Game
PreferredBackBufferWidth = options.Width,
PreferredBackBufferHeight = options.Height,
IsFullScreen = options.Fullscreen,
+ // Borderless, как и обещает GameHostOptions.Fullscreen; по умолчанию MonoGame
+ // делает эксклюзивное переключение видеорежима монитора.
+ HardwareModeSwitch = false,
SynchronizeWithVerticalRetrace = options.VSync,
};
@@ -75,4 +78,15 @@ public class GameHost : Game
Context.Scenes.ApplyPending();
base.OnExiting(sender, args);
}
+
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ Context.DisposeOwnedResources(except: this);
+ }
+
+ base.Dispose(disposing);
+ }
}
diff --git a/src/MrGameEng.Core/InputCapture.cs b/src/MrGameEng.Core/InputCapture.cs
new file mode 100644
index 0000000..0fb9a4b
--- /dev/null
+++ b/src/MrGameEng.Core/InputCapture.cs
@@ -0,0 +1,13 @@
+namespace MrGameEng.Core;
+
+///
+/// Shared flag service: true while a UI overlay (the developer console, a modal dialog)
+/// captures input. Producers (e.g. the DevConsole module) set while
+/// open; consumers (the Input module, scene UI rendering) suppress game-facing input while
+/// it is set. Lives in Core so modules can cooperate without referencing each other.
+///
+public sealed class InputCapture
+{
+ /// True while game-facing input should be suppressed.
+ public bool Captured { get; set; }
+}
diff --git a/src/MrGameEng.Core/MrGameEng.Core.csproj b/src/MrGameEng.Core/MrGameEng.Core.csproj
index 86c4479..d7ab0d1 100644
--- a/src/MrGameEng.Core/MrGameEng.Core.csproj
+++ b/src/MrGameEng.Core/MrGameEng.Core.csproj
@@ -9,4 +9,8 @@
+
+
+
+
diff --git a/src/MrGameEng.Core/Scene.cs b/src/MrGameEng.Core/Scene.cs
index cf594c2..9c615ea 100644
--- a/src/MrGameEng.Core/Scene.cs
+++ b/src/MrGameEng.Core/Scene.cs
@@ -7,6 +7,9 @@ namespace MrGameEng.Core;
/// A scene owns its ECS world () and two system roots:
/// for game logic and for rendering.
/// Override to create entities and register systems.
+/// Scene instances are single-use: populates the store and the system
+/// roots, and nothing resets them on unload — switch to a new instance instead of
+/// reloading an old one (re-loading throws).
///
public abstract class Scene
{
@@ -26,6 +29,8 @@ public abstract class Scene
public bool IsLoaded => _context is not null;
private EngineContext? _context;
+ private bool _loadedOnce;
+ private readonly List _unloadActions = [];
/// Initializes the scene's ECS world and system roots.
protected Scene()
@@ -40,6 +45,12 @@ public abstract class Scene
/// Called once when the scene is replaced or the game exits. Release scene resources here.
protected virtual void OnUnload() { }
+ ///
+ /// Registers a callback run once when the scene unloads (after ),
+ /// in reverse registration order. Engine modules use this to release per-scene resources.
+ ///
+ public void RegisterUnload(Action action) => _unloadActions.Add(action);
+
/// Runs the update phase. Called by .
public virtual void Update(GameClock clock) =>
UpdateSystems.Update(new UpdateTick(clock.DeltaTime, (float)clock.TotalTime));
@@ -50,6 +61,16 @@ public abstract class Scene
internal void Load(EngineContext context)
{
+ // Без guard'а повторная загрузка молча дублирует системы и сущности
+ // (OnLoad добавляет в те же SystemRoot/EntityStore поверх старого содержимого).
+ if (_loadedOnce)
+ {
+ throw new InvalidOperationException(
+ $"Scene '{GetType().Name}' was already loaded once. Scene instances are " +
+ "single-use: create a new instance instead of switching back to an old one.");
+ }
+
+ _loadedOnce = true;
_context = context;
OnLoad();
}
@@ -57,6 +78,12 @@ public abstract class Scene
internal void Unload()
{
OnUnload();
+ for (var i = _unloadActions.Count - 1; i >= 0; i--)
+ {
+ _unloadActions[i]();
+ }
+
+ _unloadActions.Clear();
_context = null;
}
}
diff --git a/src/MrGameEng.Core/SceneManager.cs b/src/MrGameEng.Core/SceneManager.cs
index 5199910..58ca3a7 100644
--- a/src/MrGameEng.Core/SceneManager.cs
+++ b/src/MrGameEng.Core/SceneManager.cs
@@ -36,18 +36,24 @@ public sealed class SceneManager
/// Requests a switch to . Without a transition the swap happens at
/// the start of the next update tick; with one, the old scene is first covered.
/// Passing null unloads the current scene. Calling during an active transition replaces
- /// the pending target scene.
+ /// the pending target scene; a switch requested while a transition is revealing starts
+ /// covering again from the current coverage.
///
public void Switch(Scene? scene, Transition? transition = null)
{
_pending = scene;
_hasPending = true;
- if (_state == State.Idle && transition is not null)
+ if (transition is not null && _state != State.CoveringOut)
{
_transition = transition;
+ if (_state == State.Idle)
+ {
+ _coverage = 0f;
+ }
+
+ // Из RevealingIn закрытие продолжается с текущего coverage — без скачка.
_state = State.CoveringOut;
- _coverage = 0f;
}
}
@@ -99,6 +105,13 @@ public sealed class SceneManager
_transition!.Draw(_renderer, Math.Clamp(_coverage, 0f, 1f), phase);
}
+ /// Disposes the lazily created transition renderer. Called on host shutdown.
+ internal void DisposeRenderer()
+ {
+ _renderer?.Dispose();
+ _renderer = null;
+ }
+
internal void ApplyPending()
{
if (!_hasPending)
diff --git a/src/MrGameEng.Core/ServiceRegistry.cs b/src/MrGameEng.Core/ServiceRegistry.cs
index 3b345fc..a8ccb6e 100644
--- a/src/MrGameEng.Core/ServiceRegistry.cs
+++ b/src/MrGameEng.Core/ServiceRegistry.cs
@@ -30,4 +30,23 @@ public sealed class ServiceRegistry
{
return _services.TryGetValue(typeof(T), out var service) ? (T)service : null;
}
+
+ ///
+ /// Disposes every registered service (each instance once, even
+ /// when registered under several types) and clears the registry.
+ /// is skipped. Called on host shutdown.
+ ///
+ internal void DisposeServices(object? except = null)
+ {
+ var disposed = new HashSet