«Змейка Сэмми»: XNA-игра для Zune

Ник Грэйвлин (Nick GravelynNickOnTech.com (EN))

Загрузки : https://codeplex.com/sammythesnake

ПО : Visual C# Express Editions, XNA Game Studios 3.0

Сложность: средняя.

Необходимое время: 8 часов.

Затраты: нулевые.

Художественное оформление Джорджа Клингермана (George Clingerman) — XNADevelopment.com (EN).

Примечание: В XNA используется только С#.

В захватывающем мире XNA Game Studio появилась новинка — XNA Game Studio 3.0 Beta. Эта бета-версия дает разработчикам представление о том, что будет в окончательном выпуске XNA Game Studio 3.0. Одно из основных новшеств XNA Game Studio 3.0 — возможность создавать игры для медиа-устройства Zune, выпускаемого Microsoft. В этой публикации описывается весь процесс создания игры для Zune на примере клона классической «Змейки».

Вам потребуется настроить свою среду на работу над игрой для Zune. Подробное описание этого процесса см. здесь. Прочитав и выполнив описанные действия, возвращайтесь к данному пособию.

С возвращением! Теперь, когда у вас установлена XNA Game Studio 3.0 Beta, можно начинать работу над игрой. Чтобы тестировать нашу игру в процессе ее разработки, мы сначала напишем игру под Windows, а затем просто перенесем ее на Zune. Прежде всего, откройте Visual Studio 2008 или Visual C# 2008 Express и создайте новый проект Windows. Назовите его «SammyTheSnake». Выберите место хранения проекта и нажмите OК.

Начнем с добавления содержимого в нашу игру. Джордж Клингерман любезно предоставил графические элементы для данного пособия, которые мы и добавим в проект. В Visual Studio есть панель Solution Explorer, в которой отображаются все файлы вашего проекта. В этой панели найдите узел Content вашего проекта. Щелкните его правой кнопкой и выберите Add->New Folder. Назовите новую папку «Fonts». Затем добавьте папку «Sprites». Теперь ваш Solution Explorer должен выглядеть примерно так:

Давайте добавим наши файлы содержимого. Проще всего добавить нужные спрайты в проект, перетаскивая их из соответствующей папки. Другой способ — щелкнуть правой кнопкой папку «Sprites» и выбрать Add->Existing Item. Затем перейти в каталог с графическими элементами, выбрать их и щелкнуть OК. После этого все пять изображений должны скопироваться в каталог Sprites:

Теперь создадим три шрифта, которые будут использоваться в игре. Щелкните папку «Fonts» и выберите Add->New Item. В выпадающем окне вы увидите элемент «Sprite Font». Выберите его, назовите файл «MiniFont.spritefont» и нажмите Ok. При создании он откроется в Visual Studio. Файл .spritefont — это просто XML-документ, описывающий шрифт, который вы хотите применять. Нам потребуется изменить всего два узла. Сначала найдите узел, выглядящий так:

<FontName>MiniFont</FontName>

Узел FontName указывает шрифт, который мы хотим использовать. Изменим наш шрифт на Arial:

<FontName>Arial</FontName>

Теперь изменим размер шрифта. Найдем узел «Size», в котором по умолчанию указано 14:

<Size>14</Size>

Изменим размер на 10:

<Size>10</Size>

Теперь создайте сами еще два шрифта: «MediumFont.spritefont» и «TitleFont.spritefont». Оба шрифта должны использовать шрифт Arial. Размер MediumFont должен быть 14 (стандартное значение), а TitleFont — 18.

Теперь у вас есть все содержимое, необходимое для игры. Еще раз посмотрим, все ли файлы в Solution Explorer на своих местах:

Приступим к работе над игрой — начинаем писать программу!

Для начала надо обеспечить единообразие версий для Windows и для Zune. Поскольку экран Zune имеет размер 240 на 320 пикселов, надо задать для Windows-версии такое же окно. Для этого надо добавить пару строк кода в конструктор класса Game1:

    1: public Game1()
    2: {
    3:     graphics = new GraphicsDeviceManager(this);
    4:     Content.RootDirectory = "Content";
    5:  
    6:     graphics.PreferredBackBufferWidth = 240;
    7:     graphics.PreferredBackBufferHeight = 320;
    8: }
    9:  

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

Из схемы видно, что в игре будет начальная заставка с названием, игровые сцены и конечная заставка. Стрелки показывают изменения состояния игры. С начальной заставки мы можем перейти только на игровые сцены. Оттуда можно либо выйти, снова попав на начальную заставку, либо завершить игру и попасть на конечную заставку. С конечной заставки можно перейти только на начальную, и так до тех пор, пока пользователь не закончит игру.

Теперь создадим каркас, управляющий состоянием игры, то есть переходом на выполнение необходимого кода. Для начала объявим перечисление, представляющее три состояния нашей игры. В файле Game1.cs перед объявлением класса Game добавьте такое перечисление:

    1: public enum GameState
    2: {
    3:     Title,
    4:     InGame,
    5:     GameOver
    6: }

В класс Game1 надо добавить переменную, хранящую состояние игры:

    1: private GameState state = GameState.Title;

Добавим методы для обработки изменений состояния и их визуализации. Сначала вставим в класс Game1 три новых метода:

    1: private void UpdateTitleScreen()
    2: { }
    3:  
    4: private void UpdateInGame(GameTime gameTime)
    5: { }
    6:  
    7: private void UpdateGameOver()
    8: { }

Из их имен понятно, что каждый из них соответствует одному из трех состояний игры. Метод UpdateInGame имеет входной параметр GameTime, поскольку он используется для перемещения змейки. В двух других состояниях это значение не требуется, поэтому соответствующие методы не имеют входных параметров. Изменим метод Update так, чтобы он управлял вызовом упомянутых трех методов в соответствии с состоянием игры:

    1: protected override void Update(GameTime gameTime)
    2: {
    3:     if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
    4:         this.Exit();
    5:  
    6:     if (state == GameState.Title)
    7:         UpdateTitleScreen();
    8:     else if (state == GameState.InGame)
    9:         UpdateInGame(gameTime);
   10:     else if (state == GameState.GameOver)
   11:         UpdateGameOver();
   12:  
   13:     base.Update(gameTime);
   14: }

Повторим эти шаги для трех наших методов, чтобы они визуализировали каждое состояние и изменим метод Draw таким образом, чтобы он обращался в каждом состоянии к соответствующему методу. Заодно изменим цвет фона с василькового на серый, чтобы было комфортней воспринимать белый текст, который мы добавим позднее:

    1: private void DrawTitleScreen()
    2: { }
    3:  
    4: private void DrawInGame()
    5: { }
    6:  
    7: private void DrawGameOver()
    8: { }
    9:  
   10: protected override void Draw(GameTime gameTime)
   11: {
   12:     graphics.GraphicsDevice.Clear(Color.Gray);
   13:  
   14:     if (state == GameState.Title)
   15:         DrawTitleScreen();
   16:     else if (state == GameState.InGame)
   17:         DrawInGame();
   18:     else if (state == GameState.GameOver)
   19:         DrawGameOver();
   20:  
   21:     base.Draw(gameTime);
   22: }

Теперь наша игра имеет полнофункциональную систему управления состояниями. Понятно, что ни в одном из этих состояний пока ничего не происходит, но функционал переключения между ними уже готов. Приступим к программированию начальной заставки.

Сначала надо добавить несколько текстовых строк для нее. В начале класса Game1 добавьте следующие строковые константы:

    1: private const string gameTitle = "Sammy the Snake!";
    2: private const string playInstructions = "Press Play to Begin";
    3: private const string quitInsructions = "Press Back to Quit";

Теперь добавим в класс Game1 переменные для хранения шрифтов, которыми будут отображаться текстовые строки:

    1: private SpriteFont titleFont;
    2: private SpriteFont mediumFont;
    3: private SpriteFont miniFont;

Далее в метод LoadContent добавим код для загрузки созданных нами ранее файлов .spritefont и хранения их в следующих трех переменных:

    1: protected override void LoadContent()
    2: {
    3:     spriteBatch = new SpriteBatch(GraphicsDevice);
    4:     titleFont = Content.Load<SpriteFont>("Fonts/TitleFont");
    5:     mediumFont = Content.Load<SpriteFont>("Fonts/MediumFont");
    6:     miniFont = Content.Load<SpriteFont>("Fonts/MiniFont");
    7: }

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

    1: private GamePadState gamePadState;
    2: private GamePadState lastGamePadState;

Теперь создадим метод, определяющий нажатие клавиш. Входным параметром будет проверяемая клавиша, а проверяться будет ее нажатие в gamePadState и отпускание в lastGamePadState.

    1: private bool IsNewButtonPress(Buttons button)
    2: {
    3:     return (gamePadState.IsButtonDown(button) && lastGamePadState.IsButtonUp(button));
    4: }

В метод Update надо добавить обработку изменений переменных GamePadState. В начале считывается текущее состояние, а в конце метода Update текущее состояние сохраняется в переменной с последним состоянием следующего кадра. Мы также удаляем стандартный код, осуществляющий выход при нажатии кнопки Back, поскольку обработку этого события будет выполнять метод UpdateTitleScreen:

    1: protected override void Update(GameTime gameTime)
    2: {
    3:     gamePadState = GamePad.GetState(PlayerIndex.One);
    4:  
    5:     if (state == GameState.Title)
    6:         UpdateTitleScreen();
    7:     else if (state == GameState.InGame)
    8:         UpdateInGame(gameTime);
    9:     else if (state == GameState.GameOver)
   10:         UpdateGameOver();
   11:  
   12:     lastGamePadState = gamePadState;
   13:  
   14:     base.Update(gameTime);
   15: }

Приступим к наполнению метода UpdateTitleScreen. В нашей простой игре будет отслеживаться нажатие кнопки Play/Pause для запуска игры и Back для выхода из нее. Назначим эти действия клавишам B и Back соответственно.

    1: private void UpdateTitleScreen()
    2: {
    3:     if (IsNewButtonPress(Buttons.Back))
    4:         Exit();
    5:  
    6:     if (IsNewButtonPress(Buttons.B))
    7:     { }
    8: }

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

    1: private void DrawText(SpriteFont font, string text, Vector2 position)
    2: {
    3:     Vector2 halfSize = font.MeasureString(text) / 2f;
    4:     position = position - halfSize;
    5:  
    6:     position.X = (int)position.X;
    7:     position.Y = (int)position.Y;
    8:  
    9:     spriteBatch.Begin();
   10:     spriteBatch.DrawString(
   11:         font,
   12:         text,
   13:         position, 
   14:         Color.White);
   15:     spriteBatch.End();
   16: }

Это первый достаточно сложный фрагмент кода; рассмотрим его построчно. Метод принимает три параметра: шрифт SpriteFont для отображения текста, сам текст и точку, относительно которой этот текст центрируется.

Сначала с помощью метода MeasureString шрифта вычисляется, сколько пикселов займет текст при отображении данным шрифтом. Этот размер делится на два, и полученное значение вычитается из координаты. Зачем это делается? В инфраструктуре XNA (и в большинстве API для двухмерной графики) координаты объекта указываются его левой верхней точкой. Следовательно, вычислив половину размера объекта и сместив координату на это значение, мы корректно устанавливаем координату центра объекта.

Затем мы приводим все компоненты к целым числам. Это делается для того, чтобы текст не выглядел размытым. При отображении текста могут использоваться только целые пикселы, поскольку они неделимы, и наличие координаты, не являющейся целым числом, приведет к тому, что устройство будет пытаться изменить вид текста или просто выведет его некорректно. Поэтому при рисовании двухмерных объектов всегда лучше привести координаты к целым числам.

В завершение наш метод выводит текст в нужном месте нужным шрифтом белого цвета с помощью простого метода SpriteBatch.

Теперь, посредством этого вспомогательного метода, мы можем добавить в метод DrawTitleScreen три строки для прорисовки начальной заставки:

    1: private void DrawTitleScreen()
    2: {
    3:     DrawText(titleFont, gameTitle, new Vector2(120f, 25f));
    4:     DrawText(mediumFont, playInstructions, new Vector2(120f, 200f));
    5:     DrawText(mediumFont, quitInsructions, new Vector2(120f, 225f));
    6: }

При запуске игры вы увидите такое окно:

Если к вашему компьютеру подключен контроллер Xbox 360, для выхода из игры можно нажать кнопку Back, в противном случае придется щелкнуть мышкой кнопку закрытия окна программы.

Начнем работу над самой игрой. Мой пример рассчитан на управление с помощью GamePad (поскольку мы пишем программу для Zune), так что если у вас нет контроллера Xbox 360, вам придется для отладки каждый раз устанавливать программу на Zune, либо реализовать управление с клавиатуры ПК.

Приступаем к игровому алгоритму. Игра достаточно проста. В каждый момент времени на экране есть один апельсин, змея и число съеденных ею апельсинов. Мы начнем с апельсина, затем реализуем управление змеей, а в заключение создадим систему подсчета очков.

Прежде чем начать изображать игровые сцены, надо настроить систему визуализации нашей игры. Поскольку в игре используется координатная сетка, создадим поддерживающий ее класс (статический класс Grid):

public static class Grid

{ }

Далее определим несколько констант. Первая задает размер клетки нашей сетки. Вторая равна половине размера этой клетки. Выберем размер клетки равный 16x16 пикселов, поскольку и 240 и 320 делятся без остатка на 16, и получим для выбранного нами разрешения отличную сетку. Также определим максимальные значения для числа строк и столбцов сетки:

Далее определим метод PointToVector2, который принимает координаты ячейки сетки Point и преобразует их в координаты пикселов, которые нужны для визуализации спрайта. Для этого входные координаты ячейки умножаются на масштаб и добавляется значение половины масштаба. Половина размера ячейки добавляется, чтобы результирующий Vector2 соответствовал центру клетки. Как я говорил, координаты всегда надо приводить к целым значениям, но поскольку Point содержит только целые значения и все наши константы целочисленные, нам не требуется явное приведение к Int:

    1: public static Vector2 PointToVector2(Point p)
    2: {
    3:     return new Vector2(
    4:         p.X * Scale + HalfScale,
    5:         p.Y * Scale + HalfScale);
    6: }

К классу Grid осталось добавить метод DrawSprite. Он используется для визуализации с использованием заданной текстуры в указанной точке с определенным углом поворота.

    1: public static void DrawSprite(
    2:     SpriteBatch spriteBatch, 
    3:     Texture2D texture, 
    4:     Point point, 
    5:     float rotation)
    6: {
    7:     float spriteSize = (float)Math.Max(texture.Width, texture.Height);
    8:     Vector2 origin = new Vector2(texture.Width / 2f, texture.Height / 2f);
    9:     spriteBatch.Draw(
   10:         texture,
   11:         PointToVector2(point),
   12:         null,
   13:         Color.White,
   14:         rotation,
   15:         origin,
   16:         Scale / spriteSize,
   17:         SpriteEffects.None,
   18:         0);
   19: } 

Рассмотрим работу этого метода. Сначала вычисляется больший из размеров используемой текстуры. Это делается для правильного масштабирования спрайта, чтобы он помещался в одну ячейку сетки. Затем вычисляется начало координат для визуализации. О точке отсчета я расскажу позднее, при описании параметров вызова Draw.

Далее вызывается метод spriteBatch.Draw, которому передается множество параметров, которые мы кратко рассмотрим:

· texture – представляет объект используемой текстуры.

· PointToVector2( point) – написанный нами метод Grid. PointToVector2 здесь применяется для преобразования координат решетки в координаты пикселов.

· null – этим параметром представлен исходный прямоугольник, используемый для визуализации. С помощью исходного прямоугольника задается часть области текстуры, используемая для визуализации. Поскольку нам надо отобразить всю текстуру, мы передаем здесь null.

· Color.White – использование белого цвета (white) указывает на то, что изменять цвет текстуры не требуется.

· rotation – это угол (в радианах), на который надо повернуть текстуру.

· origin – здесь используется вычисленное ранее значение начала координат. Это значение указывает, как разместить, повернуть и изменить размер текстуры. Мы указали значение в пикселах для центра спрайтов. Спрайты имеют размер 32x32 пиксела, то есть их центр отстоит на 16 пикселов. Мы не задаем это значение жестко, а делим высоту и ширину на два. Указывая центр текстуры мы устанавливаем текстуру по центру, так как второй параметр указывает, где мы разместим центр текстуры. Это также означает, что спрайт будет поворачиваться и масштабироваться относительно своего центра.

· Scale / spriteSize – здесь значение Scale нашей сетки делится на размер спрайта. Это приведет к тому, что пакет спрайтов изменит размер текстуры таким образом, чтобы она полностью помещалась в единственную клетку сетки.

· SpriteEffects.None – параметр эффектов SpriteEffects используется для транспонирования текстуры относительно горизонтальной или вертикальной оси, либо обеих осей. Указываемое нами значение не транспонирует текстуру.

· 0 – последний параметр определяет глубину слоя. Он применяется для указания порядка слоев при прорисовке спрайтов из одного пакета. Ноль указывает верхние спрайты, а единица — нижние. Мы указываем ноль, поскольку хотим управлять порядком прорисовки вручную и требуем отобразить вначале нижний спрайт. Кроме того, у нас редко будут перекрывающиеся спрайты, так что порядок их отображения для нас не проблема.

Закончив класс Grid, приступим к классу Orange:

public class Orange

{

Position = new Point(Grid.MaxColumn / 2, Grid.MaxRow / 2);

}

Какие данные нужны для класса, соответствующего апельсинам? Прежде всего — местоположение и текстура. Но нужно и третье значение — случайное число, позволяющее разместить апельсин в непредсказуемом месте экрана:

    1: public Point Position;
    2: private Texture2D texture;
    3: private Random rand = new Random();

Теперь добавим в класс Orange три небольших метода, которые будут обеспечивать практически всю необходимую функциональность нашей игры:

    1: public void Reposition()
    2: {
    3:     Position = new Point(rand.Next(Grid.MaxColumn), rand.Next(Grid.MaxRow));
    4: }
    5:  
    6: public void Load(ContentManager content)
    7: {
    8:     texture = content.Load<Texture2D>("Sprites/Orange");
    9: }
   10:  
   11: public void Draw(SpriteBatch spriteBatch)
   12: {
   13:     spriteBatch.Begin();
   14:     Grid.DrawSprite(spriteBatch, texture, Position, 0f);
   15:     spriteBatch.End();
   16: }

Метод Reposition генерирует случайную координату сетки, по которой располагается апельсин. Метод Load получает экземпляр ContentManager и обеспечивает загрузку текстуры апельсина из файла. Наконец, метод Draw получает экземпляр SpriteBatch и обеспечивает отображение апельсина с помощью метода DrawSprite класса Grid.

Прежде чем продолжить, убедимся в том, что этот класс работает как надо. Для начала вернемся к классу Game1 и добавим переменную для апельсина. Кроме того, установим начальное состояние игры в InGame:

    1: private Orange orange = new Orange();
    2: private GameState state = GameState.InGame;

Добавим в метод LoadContent вызов метода Load для апельсина:

    1: protected override void LoadContent()
    2: {
    3:     spriteBatch = new SpriteBatch(GraphicsDevice);
    4:     titleFont = Content.Load<SpriteFont>("Fonts/TitleFont");
    5:     mediumFont = Content.Load<SpriteFont>("Fonts/MediumFont");
    6:     miniFont = Content.Load<SpriteFont>("Fonts/MiniFont");
    7:  
    8:     orange.Load(Content);
    9: }

В метод UpdateInGame добавляем код, каждую секунду вызывающий метод Reposition апельсина:

    1: private void UpdateInGame(GameTime gameTime)
    2: {
    3:     if (gameTime.TotalGameTime.Milliseconds % 1000 == 0)
    4:         orange.Reposition();
    5: }

В завершение добавим в метод DrawInGame обращение к Draw:

private void DrawInGame()

{

orange.Draw(spriteBatch);

}

Соберите и запустите игру. Вы должны увидеть такое же серое окно с единственным апельсином. Каждую секунду апельсин должен перемещаться в новую ячейку сетки.

Приступим к созданию класса Snake:

public class Snake

{ }

Класс Snake будет посложней. Определим данные, необходимые этому классу:

· Список точек для каждого сегмента тела.

· Четыре текстуры для прорисовки змеи (для головы, неизогнутой и изогнутой части тела, а также хвоста).

· Значение, определяющее скорость движения змеи.

· Значения, указывающие текущее направление движения змеи и направление в следующий момент.

· Значение, указывающее, требуется ли увеличить длину змеи в следующий момент.

· Значение таймера, определяющее частоту перемещения змеи по сетке.

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

    1: public enum Direction
    2: {
    3:     Up,
    4:     Down,
    5:     Left,
    6:     Right
    7: }

Определим все переменные самого класса Snake:

    1: public const float MoveSpeed = .2f; 
    2: private float moveTimer;
    3: private Texture2D head, straight, angle, tail;
    4: private List<Point> bodyPoints = new List<Point>();
    5: private Direction currentDirection = Direction.Right;
    6: private Direction nextDirection = Direction.Right;
    7: private bool extending;

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

Определим метод Reset, вызываемый из конструктора Snake. Этот метод отвечает за размещение змеи в начальной позиции при запуске игры.

    1: public Snake()
    2: {
    3:     Reset();
    4: }
    5:  
    6: public void Reset()
    7: {
    8:     bodyPoints.Clear();
    9:  
   10:     bodyPoints.Add(new Point(2, 0));
   11:     bodyPoints.Add(new Point(1, 0));
   12:     bodyPoints.Add(new Point(0, 0));
   13:  
   14:     currentDirection = Direction.Right;
   15:     nextDirection = Direction.Right;
   16: }

В методе Reset сначала очищается список точек, описывающих тело змеи. Затем добавляются три точки тела. Первая точка соответствует голове, следующая — телу и последняя — это хвост. Мы также инициализируем текущее и следующее направление значениями Right, так что змейка начнет двигаться вправо.

Создадим метод Load, который, как и в классе Orange, обеспечивает загрузку текстур для визуализации змеи:

    1: public void Load(ContentManager content)
    2: {
    3:     head = content.Load<Texture2D>("Sprites/Head");
    4:     straight = content.Load<Texture2D>("Sprites/Straight");
    5:     angle = content.Load<Texture2D>("Sprites/Angle");
    6:     tail = content.Load<Texture2D>("Sprites/Tail");
    7: }

Далее займемся прорисовкой змейки. Это самая сложная часть программы, но мы будем двигаться пошагово и объяснять каждую строку кода. Сначала добавим пять методов:

    1: public void Draw(SpriteBatch spriteBatch)
    2: {
    3:     spriteBatch.Begin();
    4:  
    5:     for (int i = 1; i < bodyPoints.Count - 1; i++)
    6:     {
    7:         DrawBody(
    8:             spriteBatch, 
    9:             bodyPoints[i], 
   10:             bodyPoints[i - 1], 
   11:             bodyPoints[i + 1]);
   12:     }
   13:  
   14:     DrawTail(spriteBatch);
   15:     DrawHead(spriteBatch);
   16:     spriteBatch.End();
   17: }
   18:  
   19: private void DrawHead(SpriteBatch spriteBatch) { }
   20: private void DrawTail(SpriteBatch spriteBatch) { }
   21: private bool IsAnglePiece(Point current, Point last, Point next) { }
   22: private void DrawBody(SpriteBatch spriteBatch, Point current, Point last, Point next) { }
   23: private float GetAngleRotation(Point current, Point last, Point next) { }

Прорисовка будет осуществляться несколькими функциями, так что код будет более структурированным и понятным. Метод Draw — основной, он будет использоваться в классе Game1 для визуализации змеи. Единственный входной параметр этого метода — это экземпляр SpriteBatch.

В нем выполняется цикл для списка bodyPoints, начиная со второго его элемента до предпоследнего. Первый и последний элементы мы умышленно не задействуем, поскольку они представляют голову и хвост соответственно.

Нарисовав тело, отображаем голову и хвост. Порядок, в котором мы вызываем соответствующие методы, влияет на получаемое изображение. Важно нарисовать голову в последнюю очередь. В этом случае голова будет видна даже при наложении, когда она «перегнала» хвост.

Реализуем метод DrawHead:

    1: private void DrawHead(SpriteBatch spriteBatch)
    2: {
    3:     Point headPoint = bodyPoints[0];
    4:     Point nextBody = bodyPoints[1];
    5:  
    6:     float rotation;
    7:     if (headPoint.Y == nextBody.Y - 1)
    8:     {
    9:         rotation = -MathHelper.PiOver2;
   10:     }
   11:     else if (headPoint.Y == nextBody.Y + 1)
   12:     {
   13:         rotation = MathHelper.PiOver2;
   14:     }
   15:     else if (headPoint.X == nextBody.X - 1)
   16:     {
   17:         rotation = MathHelper.Pi;
   18:     }
   19:     else
   20:     {
   21:         rotation = 0f;
   22:     }
   23:  
   24:     Grid.DrawSprite(spriteBatch, head, headPoint, rotation);
   25: }

Он может показаться сложным, но на самом деле это не так. Сначала получаем координату головы и первую следующую за ней точку тела. Сравнивая эти точки, мы определяем угол, используемый при прорисовке головы. В завершение мы отображаем голову с помощью метода Grid. DrawSprite.

Реализуем метод DrawTail. Он практически идентичен методу DrawHead, так что пояснять особо нечего:

    1: private void DrawTail(SpriteBatch spriteBatch)
    2: {
    3:     Point tailPoint = bodyPoints[bodyPoints.Count - 1];
    4:     Point lastBody = bodyPoints[bodyPoints.Count - 2];
    5:  
    6:     float rotation;
    7:     if (tailPoint.Y == lastBody.Y - 1)
    8:     {
    9:         rotation = MathHelper.PiOver2;
   10:     }
   11:     else if (tailPoint.Y == lastBody.Y + 1)
   12:     {
   13:         rotation = -MathHelper.PiOver2;
   14:     }
   15:     else if (tailPoint.X == lastBody.X + 1)
   16:     {
   17:         rotation = MathHelper.Pi;
   18:     }
   19:     else
   20:     {
   21:         rotation = 0f;
   22:     }
   23:  
   24:     Grid.DrawSprite(spriteBatch, tail, tailPoint, rotation);
   25: }

Перейдем к методу IsAnglePiece. Он применяется в методе DrawBody для определения, рисовать ли данную точку в наклоненном или в ровном участке тела. Для этого сравниваются координаты текущего, предыдущего и следующего участков тела и определяется, образуют ли они прямой угол:

    1: private bool IsAnglePiece(Point current, Point last, Point next)
    2: {
    3:     return (current.X == last.X && current.X != next.X && current.Y != last.Y) ||
    4:            (current.X == next.X && current.X != last.X && current.Y != next.Y);
    5: }

Теперь можно написать достаточно простой метод DrawBody:

    1: private void DrawBody(SpriteBatch spriteBatch, Point current, Point last, Point next)
    2: {
    3:     if (IsAnglePiece(current, last, next))
    4:     {
    5:         Grid.DrawSprite(
    6:             spriteBatch, 
    7:             angle, 
    8:             current, 
    9:             GetAngleRotation(current, last, next));
   10:     }
   11:     else if (current.X != last.X)
   12:     {
   13:         Grid.DrawSprite(
   14:             spriteBatch, 
   15:             straight, 
   16:             current, 
   17:             0f);
   18:     }
   19:     else if (current.Y != last.Y)
   20:     {
   21:         Grid.DrawSprite(
   22:             spriteBatch, 
   23:             straight, 
   24:             current, 
   25:             MathHelper.PiOver2);
   26:     }
   27: }

Сначала определяем, образуют ли три точки угол. Если это так, вызываем метод Grid. DrawSprite для прорисовки угловой текстуры в данном месте. Наличие угла выясняется с помощью определенного ранее метода GetAngleRotation.

Если участок не угловой, выясняем, является ли участок тела горизонтальным или вертикальным и используем для его прорисовки угол ноль или пи пополам соответственно.

Наконец, создадим метод GetAngleRotation. Этот метод принимает три объекта Point и определяет угол, под которым надо вывести спрайт, чтобы три фрагмента тела были соединены. Вот код этого метода:

    1: private float GetAngleRotation(Point current, Point last, Point next)
    2: {
    3:     Point negPiOver2 = new Point(next.X + 1, last.Y - 1);
    4:     Point negPiOver22 = new Point(last.X + 1, next.Y - 1);
    5:  
    6:     Point pi = new Point(next.X - 1, last.Y - 1);
    7:     Point pi2 = new Point(last.X - 1, next.Y - 1);
    8:  
    9:     Point piOver2 = new Point(next.X - 1, last.Y + 1);
   10:     Point piOver22 = new Point(last.X - 1, next.Y + 1);
   11:  
   12:     if (current == negPiOver2 || current == negPiOver22)
   13:     {
   14:         return -MathHelper.PiOver2;
   15:     }
   16:     else if (current == pi || current == pi2)
   17:     {
   18:         return MathHelper.Pi;
   19:     }
   20:     else if (current == piOver2 || current == piOver22)
   21:     {
   22:         return MathHelper.PiOver2;
   23:     }
   24:     else
   25:     {
   26:         return 0f;
   27:     }
   28: }

Сначала вычисляются координаты. Каждая их пара представляет одну из комбинаций, соответствующую определенному углу. Если вам трудно это себе представить, советую взять бумагу в клеточку и нарисовать на ней эти точки.

Затем текущая точка сравнивается со всеми вычисленными значениями. В случае совпадения метод возвращает это значение. Если совпадения не найдены, значит для отображения спрайта поворот не требуется.

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

Добавим переменную для змеи:

private Snake snake = new Snake();

В метод LoadContent добавим вызов метода Load змеи:

snake.Load(Content);

Теперь вставим вызов метода Draw для змеи в DrawInGame:

    1: private void DrawInGame()
    2: {
    3:     orange.Draw(spriteBatch);
    4:     snake.Draw(spriteBatch);
    5: }

Если вы теперь соберете и запустите игру, то увидите в левом верхнем углу маленькую змейку, а также апельсин, который мы добавили ранее:

Наша игра, в ее текущем состоянии, имеет один недостаток. Апельсин, поскольку он появляется в произвольном месте экрана, может оказаться под змеей. Это нам ни к чему. Чтобы исправить ситуацию, мы добавляем в класс Snake метод IsBodyOnPoint, который будет сообщать, занята ли данная точка сетки какой-либо частью тела змеи. Для этого мы просто используем метод Contains, имеющийся у списков:

    1: public bool IsBodyOnPoint(Point p)
    2: {
    3:     return bodyPoints.Contains(p);
    4: }

Изменим метод Reposition класса Orange: он будет принимать экземпляр класса Snake и использовать его для проверки, что сгенерированная координата для апельсина не занята змеей:

    1: public void Reposition(Snake snake)
    2: {
    3:     do
    4:     {
    5:         Position = new Point(rand.Next(Grid.MaxColumn), rand.Next(Grid.MaxRow));
    6:     } while (snake.IsBodyOnPoint(Position));
    7: }

Используемый цикл «do-while» гарантирует как минимум однократное выполнение кода внутри него. Если выясняется, что точка занята змеей, цикл выполнится еще раз и так до тех пор, пока не будет найдена свободная точка.

Внесем необходимые изменения в метод UpdateInGame класса Game1:

    1: private void UpdateInGame(GameTime gameTime)
    2: {
    3:     if (gameTime.TotalGameTime.Milliseconds % 1000 == 0)
    4:         orange.Reposition(snake);
    5: }

Если вы теперь будете запускать игру, вы убедитесь, что апельсин всегда виден на экране. Теперь у нас достаточно материала для реализации алгоритма движения змеи. Добавим несколько методов в класс Snake:

    1: public void Update(GameTime gameTime)
    2: { }
    3:  
    4: private void HandleInput()
    5: { }
    6:  
    7: private void MoveSnake()
    8: { }

Эти три метода будут управлять всеми изменениями, происходящими со змеей. Заполним метод Update:

    1: public void Update(GameTime gameTime)
    2: {
    3:     HandleInput();
    4:  
    5:     moveTimer += (float)gameTime.ElapsedGameTime.TotalSeconds;
    6:  
    7:     if (moveTimer < MoveSpeed)
    8:     {
    9:         return;
   10:     }
   11:  
   12:     moveTimer = 0f;
   13:     currentDirection = nextDirection;
   14:     MoveSnake();
   15: }

Сначала он обращается к HandleInput. Затем истекшее время (в секундах) добавляется к переменной moveTimer. Затем сравниваются значения moveTimer и MoveSpeed. Если moveTimer меньше MoveSpeed, значит время еще не истекло и производится возврат из данного метода. Если же moveTimer превышает MoveSpeed, обновление продолжается. Затем moveTimer сбрасывается. Далее nextDirection (следующее направление) присваивается переменной для текущего направления currentDirection и вызывается метод движения змейки MoveSnake.

Напишем метод HandleInput:

    1: private void HandleInput()
    2: {
    3:     GamePadState gps = GamePad.GetState(PlayerIndex.One);
    4:  
    5:     if (gps.IsButtonDown(Buttons.DPadDown) && currentDirection != Direction.Up)
    6:     {
    7:         nextDirection = Direction.Down;
    8:     }
    9:     if (gps.IsButtonDown(Buttons.DPadUp) && currentDirection != Direction.Down)
   10:     {
   11:         nextDirection = Direction.Up;
   12:     }
   13:     if (gps.IsButtonDown(Buttons.DPadLeft) && currentDirection != Direction.Right)
   14:     {
   15:         nextDirection = Direction.Left;
   16:     }
   17:     if (gps.IsButtonDown(Buttons.DPadRight) && currentDirection != Direction.Left)
   18:     {
   19:         nextDirection = Direction.Right;
   20:     }
   21: }

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

Следующий метод —MoveSnake. Он отвечает за две вещи. Во-первых, он обеспечивает изменение клеток сетки в соответствии с движением змеи. Во-вторых — увеличивает длину змеи при необходимости. Вот его код:

    1: private void MoveSnake()
    2:  
    3:    Point p1 = bodyPoints[0];
    4:    switch (currentDirection)
    5:    {
    6:        case Direction.Up:
    7:            bodyPoints[0] = new Point(p1.X, p1.Y - 1);
    8:            break;
    9:        case Direction.Down:
   10:            bodyPoints[0] = new Point(p1.X, p1.Y + 1);
   11:            break;
   12:        case Direction.Left:
   13:            bodyPoints[0] = new Point(p1.X - 1, p1.Y);
   14:            break;
   15:        case Direction.Right:
   16:            bodyPoints[0] = new Point(p1.X + 1, p1.Y);
   17:            break;
   18:    }
   19:    if (extending)
   20:    {
   21:        bodyPoints.Insert(1, p1);
   22:        extending = false;
   23:        return;
   24:    }
   25:    for (int i = 1; i < bodyPoints.Count; i++)
   26:    {
   27:        Point p2 = bodyPoints[i];
   28:        bodyPoints[i] = p1;
   29:        p1 = p2;
   30:    }
   31:  

Сначала получаем координаты головы, чтобы сохранить положение оставшейся части тела. Затем используем значение currentDirection для перемещения головы в следующую позицию, в которой она должна находиться.

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

Если длина змеи не увеличивается, мы проходим в цикле по всем точкам тела, включая хвост, и смещаем их на предшествующую позицию.

Снова проверим работу нашей программы. В метод UpdateInGame класса Game1 добавим метод Update змеи:

    1: private void UpdateInGame(GameTime gameTime)
    2: {
    3:     if (gameTime.TotalGameTime.Milliseconds % 1000 == 0)
    4:         orange.Reposition(snake);
    5:     snake.Update(gameTime);
    6: }

Теперь при запуске игры змейка движется вправо. С помощью манипулятора DPad вашего контроллера Xbox 360 (или Zune, если вы установили игру на нем) вы можете изменять направление движения змейки.

Базовая функциональность игры реализована, добавим элемент азарта. Для начала «научим» нашу змею поедать апельсины. Для этого надо анализировать, находится ли голова змеи в той же ячейке сетки, что и апельсин. Значение координат апельсина нам известно, осталось написать метод, определяющий, находится ли голова змеи в той же точке. Добавим в класс Snake следующий метод:

    1: public bool IsHeadAtPosition(Point position)
    2: {
    3:     return (bodyPoints[0] == position);
    4: }

В нем первая точка массива сравнивается с заданной точкой и возвращается логическая единица, если они совпадают. Надо изменить метод UpdateInGame, чтобы местоположение апельсина изменялось после того, как его съедает змея:

    1: private void UpdateInGame(GameTime gameTime)
    2: {
    3:     snake.Update(gameTime);
    4:  
    5:     if (snake.IsHeadAtPosition(orange.Position))
    6:         orange.Reposition(snake);
    7: }

Теперь новый апельсин появляется в другом месте, когда предыдущий съедает змея, но она при этом не вырастает, как ей бы следовало. Добавляем в класс Snake метод, извещающий змею, что ей надо бы подрасти:

    1: public void Extend()
    2: {
    3:     extending = true;
    4: }

Надо изменить метод UpdateInGame таким образом, чтобы при поедании змеей апельсина не только в другом месте появлялся новый апельсин, но и чтобы удлинялась змея:

    1: private void UpdateInGame(GameTime gameTime)
    2: {
    3:     snake.Update(gameTime);
    4:  
    5:     if (snake.IsHeadAtPosition(orange.Position))
    6:     {
    7:         orange.Reposition(snake);
    8:         snake.Extend();
    9:     }
   10: }

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

    1: public bool IsLooped()
    2: {
    3:     for (int i = 1; i < bodyPoints.Count; i++)
    4:         if (IsHeadAtPosition(bodyPoints[i]))
    5:             return true;
    6:  
    7:     return false;
    8: }
    9:  
   10: public bool IsHeadOffScreen()
   11: {
   12:     Point h = bodyPoints[0];
   13:     return (h.X < 0 || h.Y < 0 || h.X >= Grid.MaxColumn || h.Y >= Grid.MaxRow);
   14: }

Первый метод, IsLooped, проверяет все точки тела, анализируя, не совпадают ли их координаты с головой. Второй метод, IsHeadOffScreen, проверяет, не выходят ли координаты головы за пределы сетки. Вернемся к методу UpdateInGame и добавим эти проверки:

    1: private void UpdateInGame(GameTime gameTime)
    2: {
    3:     snake.Update(gameTime);
    4:  
    5:     if (snake.IsHeadAtPosition(orange.Position))
    6:     {
    7:         orange.Reposition(snake);
    8:         snake.Extend();
    9:     }
   10:  
   11:     if (snake.IsLooped())
   12:         state = GameState.GameOver;
   13:  
   14:     if (snake.IsHeadOffScreen())
   15:         state = GameState.GameOver;
   16: }

Теперь появилась возможность проиграть, значит мы близки к завершению написания игры. Давайте добавим в начало метода UpdateInGame код, позволяющий пользователю вернуться к начальной заставке, нажав кнопку Back.

    1: private void UpdateInGame(GameTime gameTime)
    2: {
    3:     if (IsNewButtonPress(Buttons.Back))
    4:         state = GameState.Title;
    5:  
    6:     snake.Update(gameTime);
    7:  
    8:     if (snake.IsHeadAtPosition(orange.Position))
    9:     {
   10:         orange.Reposition(snake);
   11:         snake.Extend();
   12:     }
   13:  
   14:     if (snake.IsLooped())
   15:         state = GameState.GameOver;
   16:  
   17:     if (snake.IsHeadOffScreen())
   18:         state = GameState.GameOver;
   19: }

Код метода UpdateInGame практически готов. Осталось добавить возможность подсчета съеденных апельсинов. Добавим в класс Game1 переменную для хранения счета:

private int score;

Надо лишь увеличивать это значение всякий раз, когда змейка съедает апельсин. Измененный код метода UpdateInGame выглядит так:

    1: if (snake.IsHeadAtPosition(orange.Position))
    2: {
    3:     orange.Reposition(snake);
    4:     snake.Extend();
    5:     score++;
    6: }

Внесем в метод DrawInGame код для отображения на экране текущего счета. Добавим в класс Game1 следующую строковую константу:

private const string scoreFormat = "Oranges Eaten: {0}";

«{0}» — это заглушка, вместо которой вставляется реальное значение счета.

Добавим код отображения счета в метод DrawInGame:

    1: private void DrawInGame()
    2: {
    3:     orange.Draw(spriteBatch);
    4:     snake.Draw(spriteBatch);
    5:     DrawText(miniFont, string.Format(scoreFormat, score), new Vector2(120f, 5f));
    6: }

Запустим игру и посмотрим, что получилось:

В методы UpdateGameOver и DrawGameOver надо вставить отображение результата и ожидание ввода для перехода в начало игры. Добавляем две строковые константы:

private const string gameOver = "Game Over!";

private const string gameOverInstructions = "Press Play to Continue";

Добавляем код в эти методы:

    1: private void UpdateGameOver()
    2: {
    3:     if (IsNewButtonPress(Buttons.B))
    4:         state = GameState.Title;
    5: }
    6:  
    7: private void DrawGameOver()
    8: {
    9:     orange.Draw(spriteBatch);
   10:     snake.Draw(spriteBatch);
   11:  
   12:     DrawText(titleFont, gameOver, new Vector2(120f, 25f));
   13:     DrawText(mediumFont, string.Format(scoreFormat, score), new Vector2(120f, 200f));
   14:     DrawText(mediumFont, gameOverInstructions, new Vector2(120f, 225f));
   15: }

Как видите, мы показываем не только текст и счет, но и змейку с апельсином. Благодаря этому пользователь сможет увидеть последнюю позицию игры.

Добавим код метода UpdateTitleScreen, сбрасывающий объекты при запуске новой игры:

    1: private void UpdateTitleScreen()
    2: {
    3:     if (IsNewButtonPress(Buttons.Back))
    4:         Exit();
    5:  
    6:     if (IsNewButtonPress(Buttons.B))
    7:     {
    8:         snake.Reset();
    9:         score = 0;
   10:         orange.Reposition(snake);
   11:         state = GameState.InGame;
   12:     }
   13: }

И, наконец, инициализируем начальное состояние игры значением начальной заставки:

private GameState state = GameState.Title;

Игра готова! У вас есть полнофункциональный клон «Змейки». «А как насчет Zune?», – спросите вы. Сделаем!

XNA Game Studio всегда позиционировалась как кроссплатформенный инструмент, и появившаяся поддержка Zune это подтверждает. Перенести проект в Zune можно буквально несколькими щелчками. Сначала щелкните правой кнопкой свой проект в Solution Explorer и выберите пункт меню Create Copy of Project for Zune. По завершении процесса вы получите полный дубликат проекта, ориентированный на платформу Zune. Solution Explorer будет выглядеть примерно так:

Теперь надо собрать игру для Zune и установить ее. Это также весьма простой процесс. Если вы проработали рекомендованное пособие, вы должны это уметь, если нет — ниже описан весь процесс.

В меню Tools выберите пункт Launch XNA Game Studio Device Center. Появится окно, подобное такому:

Щелкните кнопку Add Device и вы увидите следующее диалоговое окно:

Нам надо добавить Zune, следовательно, щелкаем кнопку Zune. В окне появится Zune:

Примечание: если ваш Zune не появился в окне, убедитесь, что у вас последняя прошивка Zune, а также закрыто приложение Zune Player.

Выберите свой Zune и щелкните кнопку Next. Скоро появится сообщение об успешном завершении:

Щелкните кнопку Finish и закройте XNA Game Studio Device Center.

Щелкните созданный ранее проект Zune и в меню Build выберите Deploy Zune Copy of SammyTheSnake.

Вы должны увидеть изменение экрана Zune, отображающее установку игры. Когда на нем появится сообщение «connected» или «waiting for computer», установка игры будет завершена.

Нажмите центральную кнопку на своем Zune для выхода из XNA Game Studio Connect. В основном меню перейдите к пункту Games. В этом разделе вы обнаружите установленную вами игру. Нажмите Play и — вперед! Развлекайтесь со «Змейкой» и успехов вам в ваших приключениях при разработке игр для Zune с помощью XNA Game Studio.