diff --git a/docs/architecture.md b/docs/architecture.md index b862a52..73905cc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -138,6 +138,19 @@ public static partial class GameAssets 0 аллокаций на кадр. Контролируется бенчмарками (BenchmarkDotNet) и стресс-сценой в Sample. +## Сцены и переходы + +- `SceneManager` владеет активной сценой; обычное переключение откладывается до начала + следующего кадра (сцена никогда не выгружается посреди собственного кадра). +- `Scenes.Switch(scene, Transition.Fade(0.5f))` — переключение с визуальным переходом: + фаза закрытия (старая сцена живёт) → своп при полном покрытии → фаза открытия. + Тяжёлый `OnLoad` новой сцены скрыт за полностью закрытым экраном. +- Встроенные переходы: `Transition.Fade(duration, color)` и `Transition.Wipe(duration, color)` + (шторка). Свои — наследованием от `Transition` (рисование через `TransitionRenderer.Fill` + в нормализованных координатах экрана). +- Переходы идут по **unscaled**-времени: работают при паузе геймплея (`TimeScale = 0`). +- Повторный `Switch` во время перехода заменяет целевую сцену, не перезапуская переход. + ## Интеграция ECS - На каждую сцену создаётся свой `EntityStore` (мир) и **два** `SystemRoot`: diff --git a/samples/MrGameEng.Sample/SampleSystems.cs b/samples/MrGameEng.Sample/SampleSystems.cs index 3bb5b49..f61e9b5 100644 --- a/samples/MrGameEng.Sample/SampleSystems.cs +++ b/samples/MrGameEng.Sample/SampleSystems.cs @@ -82,9 +82,9 @@ public sealed class BounceSystem(RectF bounds) : QuerySystemПауза (P), музыка (M), переключение сцены (Tab). +/// Пауза (P), музыка (M), переключение сцены с переходом (Tab). public sealed class SceneHotkeysSystem( - EngineContext context, ActionMap actions, Func nextScene) : BaseSystem + EngineContext context, ActionMap actions, Func nextScene, Transition transition) : BaseSystem { protected override void OnUpdateGroup() { @@ -106,9 +106,9 @@ public sealed class SceneHotkeysSystem( } } - if (actions.IsPressed(SampleAction.SwitchScene)) + if (actions.IsPressed(SampleAction.SwitchScene) && !context.Scenes.IsTransitioning) { - context.Scenes.Switch(nextScene()); + context.Scenes.Switch(nextScene(), transition); } } } diff --git a/samples/MrGameEng.Sample/Scenes/MainScene.cs b/samples/MrGameEng.Sample/Scenes/MainScene.cs index 7df09ac..3177bc5 100644 --- a/samples/MrGameEng.Sample/Scenes/MainScene.cs +++ b/samples/MrGameEng.Sample/Scenes/MainScene.cs @@ -93,7 +93,7 @@ public sealed class MainScene : Scene UpdateSystems.Add(new PlayerControlSystem(player, actions, audio, beep)); UpdateSystems.Add(new BounceSystem(WorldBounds)); UpdateSystems.Add(new CameraControlSystem(camera, player, input)); - UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new StressScene())); + UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new StressScene(), Transition.Fade(0.8f))); UpdateSystems.Add(new TitleStatsSystem(Context, renderer, "Main")); var music = audio.Music; diff --git a/samples/MrGameEng.Sample/Scenes/StressScene.cs b/samples/MrGameEng.Sample/Scenes/StressScene.cs index e875da9..8473029 100644 --- a/samples/MrGameEng.Sample/Scenes/StressScene.cs +++ b/samples/MrGameEng.Sample/Scenes/StressScene.cs @@ -54,7 +54,7 @@ public sealed class StressScene : Scene UpdateSystems.Add(new BounceSystem(WorldBounds)); UpdateSystems.Add(new StressCameraSystem(camera, input)); - UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new MainScene())); + UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new MainScene(), Transition.Wipe(0.8f, Color.DarkSlateBlue))); UpdateSystems.Add(new TitleStatsSystem(Context, renderer, $"Stress {SpriteCount:N0}")); } diff --git a/src/MrGameEng.Core/SceneManager.cs b/src/MrGameEng.Core/SceneManager.cs index 2a4c68f..6cb9fff 100644 --- a/src/MrGameEng.Core/SceneManager.cs +++ b/src/MrGameEng.Core/SceneManager.cs @@ -1,39 +1,103 @@ namespace MrGameEng.Core; /// -/// Owns the active . Scene switches are deferred to the start of the -/// next update so a scene is never unloaded in the middle of its own frame. +/// Owns the active . Plain switches are deferred to the start of the next +/// update so a scene is never unloaded in the middle of its own frame. Switches with a +/// first cover the old scene, swap at full coverage (hiding even a +/// slow OnLoad), then reveal the new one. Transition time is unscaled, so it works +/// while gameplay is paused. /// public sealed class SceneManager { + private enum State + { + Idle, + CoveringOut, + RevealingIn, + } + /// The active scene, or null before the first switch is applied. public Scene? Current { get; private set; } + /// True while a transition is covering or revealing. + public bool IsTransitioning => _state != State.Idle; + private readonly EngineContext _context; private Scene? _pending; private bool _hasPending; + private Transition? _transition; + private State _state; + private float _coverage; + private TransitionRenderer? _renderer; internal SceneManager(EngineContext context) => _context = context; /// - /// Requests a switch to . The current scene is unloaded and the new - /// one loaded at the start of the next update tick. Passing null unloads the current scene. + /// 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. /// - public void Switch(Scene? scene) + public void Switch(Scene? scene, Transition? transition = null) { _pending = scene; _hasPending = true; + + if (_state == State.Idle && transition is not null) + { + _transition = transition; + _state = State.CoveringOut; + _coverage = 0f; + } } - /// Applies a pending switch, then updates the active scene. Called by the host. + /// Advances a transition and updates the active scene. Called by the host. public void Update(GameClock clock) { - ApplyPending(); + switch (_state) + { + case State.Idle: + ApplyPending(); + break; + + case State.CoveringOut: + _coverage = Advance(_coverage, +1f, _transition!.OutDuration, clock.UnscaledDeltaTime); + if (_coverage >= 1f) + { + ApplyPending(); + _state = State.RevealingIn; + } + + break; + + case State.RevealingIn: + _coverage = Advance(_coverage, -1f, _transition!.InDuration, clock.UnscaledDeltaTime); + if (_coverage <= 0f) + { + _state = State.Idle; + _transition = null; + } + + break; + } + Current?.Update(clock); } - /// Draws the active scene. Called by the host. - public void Draw(GameClock clock) => Current?.Draw(clock); + /// Draws the active scene and the transition overlay on top. Called by the host. + public void Draw(GameClock clock) + { + Current?.Draw(clock); + + if (_state == State.Idle || !_context.HasGraphicsDevice) + { + return; + } + + _renderer ??= new TransitionRenderer(_context.GraphicsDevice); + var phase = _state == State.CoveringOut ? TransitionPhase.Out : TransitionPhase.In; + _transition!.Draw(_renderer, Math.Clamp(_coverage, 0f, 1f), phase); + } internal void ApplyPending() { @@ -48,4 +112,9 @@ public sealed class SceneManager _pending = null; Current?.Load(_context); } + + private static float Advance(float coverage, float direction, float duration, float deltaTime) => + duration <= 0f + ? coverage + direction + : Math.Clamp(coverage + direction * deltaTime / duration, 0f, 1f); } diff --git a/src/MrGameEng.Core/Transition.cs b/src/MrGameEng.Core/Transition.cs new file mode 100644 index 0000000..84bd122 --- /dev/null +++ b/src/MrGameEng.Core/Transition.cs @@ -0,0 +1,73 @@ +using Microsoft.Xna.Framework; + +namespace MrGameEng.Core; + +/// Phase of a scene transition. +public enum TransitionPhase +{ + /// The old scene is being covered (coverage grows 0 → 1). + Out, + + /// The new scene is being revealed (coverage shrinks 1 → 0). + In, +} + +/// +/// Visual transition between scenes. The scene switch itself happens at full coverage, +/// so a slow OnLoad of the next scene is hidden behind the overlay. +/// Transitions are stateless and reusable; progress is tracked by . +/// Runs on unscaled time, so it works while gameplay is paused. +/// +public abstract class Transition +{ + /// Seconds the covering phase takes. + public float OutDuration { get; } + + /// Seconds the revealing phase takes. + public float InDuration { get; } + + /// Creates a transition with explicit phase durations. + protected Transition(float outDuration, float inDuration) + { + OutDuration = Math.Max(0f, outDuration); + InDuration = Math.Max(0f, inDuration); + } + + /// + /// Draws the overlay. is 0 (scene fully visible) to + /// 1 (scene fully covered); tells which side of the switch this is. + /// + public abstract void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase); + + /// Fade through a solid color (black by default). Total duration is split between out and in. + public static Transition Fade(float duration = 0.6f, Color? color = null) => + new FadeTransition(duration / 2f, duration / 2f, color ?? Color.Black); + + /// A curtain wiping across the screen (black by default). Total duration is split between out and in. + public static Transition Wipe(float duration = 0.6f, Color? color = null) => + new WipeTransition(duration / 2f, duration / 2f, color ?? Color.Black); + + private sealed class FadeTransition(float outDuration, float inDuration, Color color) + : Transition(outDuration, inDuration) + { + public override void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase) => + renderer.Fill(0f, 0f, 1f, 1f, color, coverage); + } + + private sealed class WipeTransition(float outDuration, float inDuration, Color color) + : Transition(outDuration, inDuration) + { + public override void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase) + { + // Out: шторка растёт слева направо; In: уезжает дальше вправо. + if (phase == TransitionPhase.Out) + { + renderer.Fill(0f, 0f, coverage, 1f, color); + } + else + { + renderer.Fill(1f - coverage, 0f, coverage, 1f, color); + } + } + } +} diff --git a/src/MrGameEng.Core/TransitionRenderer.cs b/src/MrGameEng.Core/TransitionRenderer.cs new file mode 100644 index 0000000..d56d15f --- /dev/null +++ b/src/MrGameEng.Core/TransitionRenderer.cs @@ -0,0 +1,60 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace MrGameEng.Core; + +/// +/// Minimal overlay renderer handed to : fills rectangles in +/// normalized screen coordinates (0..1 on both axes) over the rendered scene. +/// +public sealed class TransitionRenderer +{ + private readonly GraphicsDevice _device; + private readonly BasicEffect _effect; + private readonly VertexPositionColor[] _vertices = new VertexPositionColor[6]; + + internal TransitionRenderer(GraphicsDevice device) + { + _device = device; + _effect = new BasicEffect(device) + { + VertexColorEnabled = true, + TextureEnabled = false, + World = Matrix.Identity, + View = Matrix.Identity, + Projection = Matrix.CreateOrthographicOffCenter(0f, 1f, 1f, 0f, 0f, 1f), + }; + } + + /// + /// Fills a rectangle given in normalized screen coordinates with + /// at the given (0 = invisible, 1 = solid). + /// + public void Fill(float x, float y, float width, float height, Color color, float opacity = 1f) + { + var alpha = (byte)(Math.Clamp(opacity, 0f, 1f) * color.A); + var premultiplied = Color.FromNonPremultiplied(color.R, color.G, color.B, alpha); + + var topLeft = new Vector3(x, y, 0f); + var topRight = new Vector3(x + width, y, 0f); + var bottomLeft = new Vector3(x, y + height, 0f); + var bottomRight = new Vector3(x + width, y + height, 0f); + + _vertices[0] = new VertexPositionColor(topLeft, premultiplied); + _vertices[1] = new VertexPositionColor(topRight, premultiplied); + _vertices[2] = new VertexPositionColor(bottomLeft, premultiplied); + _vertices[3] = new VertexPositionColor(bottomLeft, premultiplied); + _vertices[4] = new VertexPositionColor(topRight, premultiplied); + _vertices[5] = new VertexPositionColor(bottomRight, premultiplied); + + _device.BlendState = BlendState.AlphaBlend; + _device.DepthStencilState = DepthStencilState.None; + _device.RasterizerState = RasterizerState.CullNone; + + foreach (var pass in _effect.CurrentTechnique.Passes) + { + pass.Apply(); + _device.DrawUserPrimitives(PrimitiveType.TriangleList, _vertices, 0, 2); + } + } +} diff --git a/tests/MrGameEng.Core.Tests/SceneTransitionTests.cs b/tests/MrGameEng.Core.Tests/SceneTransitionTests.cs new file mode 100644 index 0000000..bbc5735 --- /dev/null +++ b/tests/MrGameEng.Core.Tests/SceneTransitionTests.cs @@ -0,0 +1,108 @@ +using MrGameEng.Core; +using Xunit; + +namespace MrGameEng.Core.Tests; + +public class SceneTransitionTests +{ + private sealed class TrackingScene : Scene + { + public int LoadCount; + public int UnloadCount; + + protected override void OnLoad() => LoadCount++; + + protected override void OnUnload() => UnloadCount++; + } + + private static void Tick(EngineContext context, float seconds) + { + context.Clock.Advance(seconds); + context.Scenes.Update(context.Clock); + } + + [Fact] + public void TransitionSwitch_KeepsOldScene_UntilFullyCovered() + { + var context = new EngineContext(); + var first = new TrackingScene(); + var second = new TrackingScene(); + context.Scenes.Switch(first); + Tick(context, 0.016f); + + // Fade(1.0) → фаза закрытия 0.5 c, фаза открытия 0.5 c. + context.Scenes.Switch(second, Transition.Fade(1f)); + Tick(context, 0.2f); + + Assert.True(context.Scenes.IsTransitioning); + Assert.Same(first, context.Scenes.Current); + Assert.Equal(0, second.LoadCount); + + Tick(context, 0.4f); // суммарно 0.6 > 0.5 — экран закрыт, своп произошёл + + Assert.Same(second, context.Scenes.Current); + Assert.Equal(1, first.UnloadCount); + Assert.Equal(1, second.LoadCount); + Assert.True(context.Scenes.IsTransitioning); // идёт фаза открытия + + Tick(context, 0.6f); // открытие завершено + + Assert.False(context.Scenes.IsTransitioning); + Assert.Same(second, context.Scenes.Current); + } + + [Fact] + public void Transition_UsesUnscaledTime_WorksWhilePaused() + { + var context = new EngineContext(); + var first = new TrackingScene(); + var second = new TrackingScene(); + context.Scenes.Switch(first); + Tick(context, 0.016f); + context.Clock.TimeScale = 0f; // игра на паузе + + context.Scenes.Switch(second, Transition.Fade(0.2f)); + Tick(context, 0.15f); + Tick(context, 0.15f); + + Assert.Same(second, context.Scenes.Current); + Assert.False(context.Scenes.IsTransitioning); + } + + [Fact] + public void SwitchDuringTransition_ReplacesPendingTarget() + { + var context = new EngineContext(); + var first = new TrackingScene(); + var second = new TrackingScene(); + var third = new TrackingScene(); + context.Scenes.Switch(first); + Tick(context, 0.016f); + + context.Scenes.Switch(second, Transition.Fade(1f)); + Tick(context, 0.1f); + context.Scenes.Switch(third); // передумали, пока экран закрывается + + Tick(context, 0.5f); + + Assert.Same(third, context.Scenes.Current); + Assert.Equal(0, second.LoadCount); + } + + [Fact] + public void ZeroDurationTransition_SwapsOnNextUpdates() + { + var context = new EngineContext(); + var first = new TrackingScene(); + var second = new TrackingScene(); + context.Scenes.Switch(first); + Tick(context, 0.016f); + + context.Scenes.Switch(second, Transition.Fade(0f)); + Tick(context, 0.016f); + Tick(context, 0.016f); + + Assert.Same(second, context.Scenes.Current); + Assert.False(context.Scenes.IsTransitioning); + } +}