Fix engine-wide code review findings
CI / build-test (push) Failing after 1m7s

Collisions: init bucket heads to -1 (QueryAabb hung before the first
rebuild), reset query stamps on truncated QueryAabb (later queries
silently dropped entities), inside-origin raycasts hit at fraction 0 for
circles too, exactly-touching boxes now pair like touching circles.

Graphics: render into the letterbox viewport so the picture matches
ScreenToWorld/WorldToScreen instead of stretching; Y-sort by the
transform pivot rather than the quad center; lock-free snapshot
LayerRegistry (parallel submit read it unsynchronized); validate
InitialCapacity; warn when UseRenderer2D drops options of a later scene.

Core: scenes are explicitly single-use (re-loading threw silently
duplicated systems/entities before — now it throws), Scene.RegisterUnload
for per-scene resources, a switch requested during the reveal phase
covers again instead of hard-swapping, borderless fullscreen
(HardwareModeSwitch off), InputCapture service for input-suppressing
overlays, host disposes the transition renderer and IDisposable services
on shutdown.

Input: game input reads as released while InputCapture is held; mouse
position and wheel freeze so deltas stay zero.

DevConsole: holds InputCapture while open (typing no longer drives the
camera), Revision increments only under the lock, quoted command
arguments, history capped at 256.

UI: scene Desktop skips Myra input processing while the console is open
(clicks no longer fall through), is disposed on scene unload, and Myra
init no longer depends on a process-static flag.

Audio: validate channel count/sample rate before stopping the previous
track, empty looped oggs no longer hang FillBuffers, the instance stops
when a non-looping track drains (IsPlaying was stuck true).

Atlases: metadata v2 stores per-source size+mtime snapshots, so
timestamp-preserving copies and renames invalidate correctly; loader
checks the version and disposes pages on partial load failure; shared
pages never exceed a non-POT MaxPageSize; oversized items pack first
onto exact-size pages instead of splitting an open shared page; the CLI
validates numeric options.

Assets.Generator: file names are escaped in XML docs and string
literals, members no longer collide with the enclosing class (CS0542),
and the Assets root is resolved against build_property.projectdir so
nested "Assets" directories do not shift region paths.

Pathfinding: queries throw when the grid was resized after construction;
generation stamps survive int overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-11 21:18:08 +03:00
co-authored by Claude Fable 5
parent 56f2a85478
commit 501d81e19f
35 changed files with 926 additions and 98 deletions
+36 -12
View File
@@ -23,7 +23,11 @@ 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>
/// <summary>
/// Order by the entity's world Y position (<see cref="Transform2D.Position"/>):
/// top-down games, lower on screen = drawn in front. Place sprite origins at the
/// feet/base so the sort point matches the visual anchor.
/// </summary>
YSort,
}
@@ -32,31 +36,51 @@ public sealed record RenderLayer(LayerId Id, string Name, LayerSpace Space, Laye
/// <summary>
/// Registry of render layers. Layers are registered up front (typically when the renderer is
/// created) and drawn in registration order. Maximum 256 layers.
/// created) and drawn in registration order. Maximum 256 layers. Reads are lock-free and
/// thread-safe (the renderer reads layers from parallel submit workers); registration swaps
/// an immutable snapshot, so registering mid-frame never tears a concurrent read.
/// </summary>
public sealed class LayerRegistry
{
private readonly List<RenderLayer> _layers = [];
private readonly object _sync = new();
private volatile 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;
public int Count => _layers.Length;
/// <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)
lock (_sync)
{
throw new InvalidOperationException("Maximum number of render layers (256) reached.");
}
var layers = _layers;
if (layers.Length == 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;
var id = new LayerId((byte)layers.Length);
var grown = new RenderLayer[layers.Length + 1];
Array.Copy(layers, grown, layers.Length);
grown[layers.Length] = new RenderLayer(id, name, space, sortMode);
_layers = grown;
return id;
}
}
/// <summary>Returns the layer with the given id.</summary>
public RenderLayer this[LayerId id] => _layers[id.Value];
/// <summary>Returns the layer with the given id; throws when the id was never registered.</summary>
public RenderLayer this[LayerId id]
{
get
{
var layers = _layers;
return id.Value < layers.Length
? layers[id.Value]
: throw new ArgumentOutOfRangeException(
nameof(id), $"Render layer {id.Value} is not registered (registered: {layers.Length}).");
}
}
}
+23 -1
View File
@@ -67,6 +67,7 @@ public sealed class Renderer2D : IDisposable
{
_device = device;
_options = options ?? new Renderer2DOptions();
ArgumentOutOfRangeException.ThrowIfLessThan(_options.InitialCapacity, 1, nameof(options));
_batcher = new SpriteBatcher(_options.InitialCapacity);
_vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4];
_vertexBuffer = new DynamicVertexBuffer(
@@ -201,7 +202,26 @@ public sealed class Renderer2D : IDisposable
_device.SetVertexBuffer(_vertexBuffer);
_device.Indices = _indexBuffer;
// Виртуальное разрешение рисуется в letterbox-прямоугольник — тот же, по которому
// считают ScreenToWorld/WorldToScreen; иначе картинка растягивается мимо маппинга.
var previousViewport = _device.Viewport;
var letterboxed = _options.VirtualResolution is not null;
if (letterboxed)
{
var mapping = Camera.Mapping;
_device.Viewport = new Viewport(
(int)MathF.Round(mapping.Offset.X),
(int)MathF.Round(mapping.Offset.Y),
(int)MathF.Round(Camera.VirtualWidth * mapping.Scale),
(int)MathF.Round(Camera.VirtualHeight * mapping.Scale));
}
DrawBatches(order, count);
if (letterboxed)
{
_device.Viewport = previousViewport;
}
DrawMs = ToMs(Stopwatch.GetTimestamp() - uploadEnd);
}
@@ -276,7 +296,9 @@ public sealed class Renderer2D : IDisposable
return SubmitResult.Culled;
}
var depth = layer.SortMode == LayerSortMode.YSort ? center.Y : sprite.Depth;
// Y-sort по пивоту (Transform2D.Position), а не по центру квада: спрайты разной
// высоты с origin «в ногах» сортируются по ногам, как принято в top-down.
var depth = layer.SortMode == LayerSortMode.YSort ? transform.Position.Y : sprite.Depth;
instance = new SpriteInstance
{
@@ -22,6 +22,12 @@ public static class SceneGraphicsExtensions
renderer = new Renderer2D(scene.Context.GraphicsDevice, options);
services.Add(renderer);
}
else if (options is not null)
{
Log.Warning(
"UseRenderer2D: the renderer already exists, the passed options are ignored " +
"(Renderer2D is a shared service configured by its first user).");
}
scene.DrawSystems.Add(new CameraSystem(renderer));
scene.DrawSystems.Add(new SpriteRenderSystem(renderer));