Урок 2: Создание и использование стека экранов в XNA

Даже в самой маленькой игре нужно как минимум три экрана: меню, главная форма игры, результаты (обычно рейтинговая таблица).  Смену нескольких экранов удобно реализовать при помощи стека экранов. Теоретически это выглядит так:
стандартными средствами языка C# можно объявить переменную типа стек. В этот стек в требуемой последовательности добавляются экраны. Отображается тот экран, который находиться на вершине стека. Когда экран выполнил свои функции, его удаляют из стека. Как только из стека удалили последний экран, программа заканчивает свою работу. При помощи такого механизма можно реализовать самые сложные переходы.

Теперь я покажу на примере, как это можно сделать.

1. В проект добавляем новый класс, который должен стать базовым для всех игровых экранов. Назовем его IGameScreen. Он, разумеется абстрактный:

public abstract class IGameScreen
{
protected GraphicsDeviceManager graphics;
protected SpriteBatch spriteBatch;
protected Game game;
protected ContentManager content;
protected Stack<IGameScreen> stack;

public abstract bool Update(GameTime gameTime, KeyboardState lastKeyState, MouseState lastMouseState);

public abstract void Draw(GameTime gameTime);
}

Свойства graphics, spriteBatch, game и content нужны будут экранам для загрузки данных, отрисовки и обратной связи с игрой.
Метод Update вызывается из Update‘a игры, Draw — соответственно, из метода Draw игры. Они отвечают за обновление состояния экрана и его отрисовку. Метод Update возвращает значение логического типа, которое истинно, если экран остается в рабочем состоянии, и ложно — когда он выполнил свою работу и должен быть удален из стека.

Свойство

protected Stack<IGameScreen> stack;

и есть ссылка на наш будущий стек экранов — эта ссылка нужна для взаимодействия между экранами. Например, экран паузы в игре может запрашивать метод отрисовки у экрана, лежащего ниже — то есть сначала рисуется состояние игры, а уже поверх него рисуется сообщение экрана паузы.

2. Теперь нужно организовать цикл обновления и прорисовки экранов. Для этого обратимся сначала к основному классу игры — потомку Game — и добавим туда свойство-стек экранов:

protected Stack<IGameScreen> stack;

В методе Initialize создадим стек экранов:

stack = new Stack<IGameScreen>();

И реализуем обработку экранов в методах Draw и Update:

protected override void Update(GameTime gameTime)
        {
            if (!(stack.Peek()).Update(gameTime, lastKeyState, lastMouseState))
                stack.Pop();
            if (stack.Count == 0)
                this.Exit();
            lastKeyState = Keyboard.GetState(PlayerIndex.One);
            lastMouseState = Mouse.GetState();
            base.Update(gameTime);
        }
        //////////////////////////////////////////////////////////////////////////////////////
        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.Black);
            stack.Peek().Draw(gameTime);
            base.Draw(gameTime);
        }

Метод Update получает экран, находящийся в вершине стека (Peek()) вызывает в свою очередь у него метод Update, передавая туда игровое время и состояние клавиатуры и мыши, запомненное на предыдущем шаге обновления. Если в результате работы этого метода вернулась ложь, то экран выполнил свою работу и должен быть уничтожен (удален из стека — Pop()). После обработки экрана проверяем, есть ли еще экраны в стеке. Если нет — то завершаем работу.
Метод Draw просто берет экран из вершины стека и вызывает у него метод отрисовки.

3. Теперь нужно создать сами экраны. Для примера мы ограничимся всего двумя — экраном главного меню и экраном сплэш-заставки.
Добавим в проект новый класс — MenuScreen, наследника IGameScreen:

    public class MenuScreen: IGameScreen
    {
        Texture2D title, play, playOver, exit, exitOver;
        bool activePlay = false, activeExit = false;
        Rectangle titlePos, playPos, exitPos;
        Texture2D cursor;
        Rectangle cursorPosition;
        
        public MenuScreen(GraphicsDeviceManager gdm, SpriteBatch sb, Game g, ContentManager cnt, Stack<IGameScreen> s)
        {
            graphics = gdm;
            spriteBatch = sb;
            game = g;
            content = cnt;
            stack = s;
            Initialize();
        }

        public void Initialize()
        {
            ///Realization
            title = content.Load<Texture2D>("Title");
            play = content.Load<Texture2D>("Play1");
            playOver = content.Load<Texture2D>("Play2");
            exit = content.Load<Texture2D>("Exit1");
            exitOver = content.Load<Texture2D>("Exit2");
            cursor = content.Load<Texture2D>("cursor");

            int summary = title.Height + play.Height + exit.Height + 30 * 2;
            titlePos = new Rectangle(graphics.GraphicsDevice.Viewport.Width / 2 - title.Width / 2, graphics.GraphicsDevice.Viewport.Height / 2 - summary / 2, title.Width, title.Height);
            playPos = new Rectangle(graphics.GraphicsDevice.Viewport.Width / 2 - play.Width / 2, titlePos.Top + title.Height+30, play.Width, play.Height);
            exitPos = new Rectangle(graphics.GraphicsDevice.Viewport.Width / 2 - exit.Width / 2, playPos.Top + 30+play.Height, exit.Width, exit.Height);
            ///Realization end
        }

        public override bool Update(GameTime gameTime, KeyboardState lastKeyState, MouseState lastMouseState)
        {
            ///Realization
            if ((Keyboard.GetState(PlayerIndex.One).IsKeyDown(Keys.Escape) == true) &&
                (lastKeyState.IsKeyUp(Keys.Escape) == true))
                return false;


            activePlay = playPos.Intersects(new Rectangle(Mouse.GetState().X, Mouse.GetState().Y, 1, 1));
            activeExit = exitPos.Intersects(new Rectangle(Mouse.GetState().X, Mouse.GetState().Y, 1, 1));

            if (activePlay && ((Mouse.GetState().LeftButton == ButtonState.Pressed) && (lastMouseState.LeftButton == ButtonState.Released)))
            {  stack.Push(new MainScreen(graphics, spriteBatch, game, content, stack)); } 

            if (activeExit && ((Mouse.GetState().LeftButton == ButtonState.Pressed) && (lastMouseState.LeftButton == ButtonState.Released)))
            { return false; }

            cursorPosition = new Rectangle(Mouse.GetState().X, Mouse.GetState().Y, cursor.Width, cursor.Height);
            ///Realization end
            return true;
        }

        public override void Draw(GameTime gameTime)
        {
            spriteBatch.Begin();
            ///Realization
            spriteBatch.Draw(title, titlePos, Color.White);
            if (!activePlay)
                spriteBatch.Draw(play, playPos, Color.White);
            else
                spriteBatch.Draw(playOver, playPos, Color.White);
            if (!activeExit)
                spriteBatch.Draw(exit, exitPos, Color.White);
            else
                spriteBatch.Draw(exitOver, exitPos, Color.White);

            spriteBatch.Draw(cursor, cursorPosition, Color.White);
            ///Realization end
            spriteBatch.End();
        }
    }
}

Этот пример приведен из реального проекта, и особо останавливаться на коде, заключенном в блоки

///Realization
///Realization end

мы не будем. Важней рассмотреть другое: конструктор экрана должен вызываться после создания основных игровых объектов — ContentManager‘a, GraphicDevice‘a и SpriteBatch‘a, то есть не ранее события Initialize игры. Подробнее о том, как работать с курсором, я уже писала, а про работу со спрайтами — напишу чуть позже.
Конструктор экрана инициализирует основные свойства экрана, обеспечивающие взаимодействие с содержимым, интерфейсом и игрой, переданными параметрами, и вызывает собственный метод Initialize, который должен подготовить специфичные для экрана данные — используемые картинки, курсоры, шрифты.
Метод Update экрана принимает данные от игры, и реализовывает логику экрана, возвращая false при завершении своей работы (по умолчанию, он возвращает true, что и отмечено токеном return true в конце процедуры).
Замечу, что свойство stack у экрана нужно именно для того, чтобы он мог порождать другие экраны — например, меню должно показывать экран статистики или запускать основной игровой экран. Это делается, в нашем примере, в методе Update командой:

{  stack.Push(new MainScreen(graphics, spriteBatch, game, content, stack)); } 

Метод Draw запускает SpriteBatch и отрисовывает нужный интерфейс.
Аналогично этому экрану, создадим экран со сплэш-заствакой:

    public class WelcomeScreen: IGameScreen
    {
        Texture2D picture;
        Color color = Color.Black;
        int direction = 1;
        Rectangle r;
        double lastGameTime;

        public WelcomeScreen(GraphicsDeviceManager gdm, SpriteBatch sb, Game g, ContentManager cnt, Stack<IGameScreen> s)
        {
            graphics = gdm;
            spriteBatch = sb;
            game = g;
            content = cnt;
            stack = s;
            Initialize();
        }

        public void Initialize()
        {
            ///Realization
            picture = content.Load<Texture2D>("Title");
            r = new Rectangle((graphics.GraphicsDevice.Viewport.Width - picture.Width) / 2,
                    (graphics.GraphicsDevice.Viewport.Height - picture.Height) / 2,
                    picture.Width,picture.Height);
           ///Realization end
        }

        public override bool Update(GameTime gameTime, KeyboardState lastKeyState, MouseState lastMouseState)
        {
            ///Realization
            game.IsMouseVisible = false;
            if (gameTime.TotalGameTime.TotalMilliseconds - lastGameTime < 1) return true;
            if (direction > 0)
            {
                color.R += 1;
                color.G += 1;
                color.B += 1;
                if (color.R == 255)
                {
                    direction = -1;
                }
            }
            else
            {
                color.R -= 1;
                color.G -= 1;
                color.B -= 1;
                if (color.R == 0)
                {
                    return false;
                }
            }
            lastGameTime = gameTime.TotalGameTime.TotalMilliseconds;
            ///Realization end
            return true;
        }

        public override void Draw(GameTime gameTime)
        {
            spriteBatch.Begin();
            ///Realization
            spriteBatch.Draw(picture, r, color);
            ///Realization end
            spriteBatch.End();
        }
    }

Прошу заметить, что никакой «логики» в методе Draw быть не должно! Вся логика — в Update.
После добавления классов-экранов нужно их включить в стек игры. Для этого обращаемся к методу Initialize игры:

            stack = new Stack<IGameScreen>();
            stack.Push(new MenuScreen(graphics, spriteBatch, this, Content, stack));
            stack.Push(new WelcomeScreen(graphics, spriteBatch, this, Content, stack));

и после создания стека добавляем в него сначала основной экран, а затем экран сплэша. Получится, что сначала отработает экран заставки, самозавершится, и управление будет автоматически передано основному экрану. Еще раз отмечу, что если экран добавлен позже, то он будет лежать ближе к вершине стека экранов и получит управление раньше.