Открыть список
Как стать автором
Обновить

Impressive Solids: делаем игру на C# под OpenGL, часть II

Разработка игр
Скриншот оформленной игры В первой части разработки тетрисоподобной игры Impressive Solids мы реализовали основную часть геймплея, уделив минимальное внимание внешнему виду приложения. Мы и OpenGL-то почти не использовали, всего и делали, что рисовали цветной прямоугольник. Пришла пора заняться оформлением, а также реализовать подсчёт очков и хранение рекорда (high score). Ну что, поехали дальше.

Picture This


Займёмся текстурами. Нам нужно, во-первых, натянуть что-нибудь на фон окна, а во-вторых, сделать приятно выглядящие блоки (сейчас это просто цветные прямоугольники). Понятное дело, сначала надо изготовить текстуры. В этом нам поможет GIMP. Если у вас нет желания заниматься графикой, можете просто скачать архив с готовыми текстурами и переходить к следующему этапу.

Но сперва отмечу один очень важный нюанс. До версии OpenGL 2.0 каждый из размеров текстуры обязан был быть равным степени двойки (т. е. 64×64, 512×256; это POT-текстуры, от англ. power of two). Если текстуры произвольного размера (NPOT-текстуры) не поддерживаются видеокартой или драйвером видеокарты, такая текстура не будет работать. Это имеет место, например, для встроенных видеокарт Intel под Windows XP.

Чтобы гарантированно обезопасить себя от этой проблемы, самое простое и удобное решение — всегда использовать POT-текстуры. Однако это не всегда возможно, и дальше, когда мы дойдём до вывода текста, нам придётся заняться этим моментом.

Итак, создаём в GIMP пустое (белое) изображение 512×512, далее: Filters → Artistic → Apply Canvas, затем: Filters → Map → Make Seamless. Всё, background.png готов.

Блоки попытаемся изобразить как мраморные шарики, в этом нам поможет Дэниел Кетчум. Создаём прозрачное изображение где-то 300×300, делаем круглое выделение на весь диаметр холста. Инструмент Bucket Fill → Pattern fill → выбираем текстуру Marble #1, заливаем круг. Далее: Filters → Distort → Lens Distortion, крутим Main на максимум, а Edge — на минимум, OK. Затем: Filters → Light and Shadow → Lighting Effects, ставим свет так, чтобы создать эффект трёхмерного шара. Кадрируем, чтобы не было пустых полей. Масштабируем до размера 256×256. Потом при помощи Colors → Colorize делаем пять разных цветов (крутим Hue) и сохраняем как 0.png, 1.png… 4.png (помним, что в игровой модели мы решили обозначать разные цвета блоков целым числом начиная с нуля).

Теперь надо занести эти файлы в проект Visual C# Express / MonoDevelop. Сперва через контекстное меню создаём New Folder с именем textures, а в нём — solids. Через файловый менеджер помещаем в textures файл background.png, а в textures/solids — файлы 0.png… 4.png. Через контекстное меню в среде разработки добавляем эти файлы в проект в соответствующие папки.

После этого необходимо для всех файлов *.png в проекте открыть Properties и выставить Build Action: Content; Copy to Output Directory: Copy if newer.

Задействуем текстуры в OpenGL. Сама OpenGL не оперирует графическими файлами, нужно своими средствами (в этом поможет System.Drawing.Bitmap) загрузить текстуру в память, получить из неё бинарный bitmap и его передать в OpenGL, которая сохранит текстуру уже в своей памяти. В дальнейшем обращаться к текстуре можно будет через целочисленный handle (который сперва надо зарезервировать).

Инкапсулируем этот механизм в виде нового класса Texture.

using System;
using System.Drawing;
using System.Drawing.Imaging;
using OpenTK.Graphics.OpenGL;

namespace ImpressiveSolids {
    public class Texture : IDisposable {
        public int GlHandle { get; protected set; }
        public int Width { get; protected set; }
        public int Height { get; protected set; }

        public Texture(Bitmap Bitmap) {
            GlHandle = GL.GenTexture();
            Bind();

            Width = Bitmap.Width;
            Height = Bitmap.Height;

            var BitmapData = Bitmap.LockBits(new Rectangle(0, 0, Bitmap.Width, Bitmap.Height), ImageLockMode.ReadOnly, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
            GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba, BitmapData.Width, BitmapData.Height, 0, OpenTK.Graphics.OpenGL.PixelFormat.Bgra, PixelType.UnsignedByte, BitmapData.Scan0);
            Bitmap.UnlockBits(BitmapData);

            GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
            GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
        }

        public void Bind() {
            GL.BindTexture(TextureTarget.Texture2D, GlHandle);
        }

        #region Disposable

        private bool Disposed = false;

        public void Dispose() {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        protected virtual void Dispose(bool Disposing) {
            if (!Disposed) {
                if (Disposing) {
                    GL.DeleteTexture(GlHandle);
                }
                Disposed = true;
            }
        }

        ~Texture() {
            Dispose(false);
        }

        #endregion
    }
}


В Game загрузим текстуры.

using System.Drawing;

// . . .

private Texture TextureBackground;
private Texture[] ColorTextures = new Texture[ColorsCount];

public Game()
    : base(NominalWidth, NominalHeight, GraphicsMode.Default, "Impressive Solids") {
    VSync = VSyncMode.On;

    Keyboard.KeyDown += new EventHandler<KeyboardKeyEventArgs>(OnKeyDown);

    TextureBackground = new Texture(new Bitmap("textures/background.png"));
    for (var i = 0; i < ColorsCount; i++) {
        ColorTextures[i] = new Texture(new Bitmap("textures/solids/" + i + ".png"));
    }
}


Изменим рендеринг. Надо включить текстуры в целом, а также режим прозрачности. Затем перед тем, как задать координаты прямоугольника, надо выбрать (bind) соответствующую текстуру. Перед каждой точкой (vertex) надо задать соответствующие координаты текстуры (считается, что (1; 1) — правый нижний угол текстуры).

protected override void OnLoad(EventArgs E) {
    base.OnLoad(E);

    GL.Enable(EnableCap.Texture2D);
    GL.Enable(EnableCap.Blend);

    GL.BlendFunc(BlendingFactorSrc.SrcAlpha, BlendingFactorDest.OneMinusSrcAlpha);

    New();
}

protected override void OnRenderFrame(FrameEventArgs E) {
    // . . .
    GL.LoadMatrix(ref Modelview);

    RenderBackground();

    for (var X = 0; X < MapWidth; X++) {
        for (var Y = 0; Y < MapHeight; Y++) {
            if (Map[X, Y] >= 0) {
                RenderSolid(X, Y + ImpactFallOffset[X, Y], Map[X, Y]);
            }
        }
    }

    if (GameStateEnum.Fall == GameState) {
        for (var i = 0; i < StickLength; i++) {
            RenderSolid(StickPosition.X + i, StickPosition.Y, StickColors[i]);
        }
    }

    SwapBuffers();
}

private void RenderBackground() {
    TextureBackground.Bind();
    GL.Color4(Color4.White);
    GL.Begin(BeginMode.Quads);

    GL.TexCoord2(0, 0);
    GL.Vertex2(0, 0);

    GL.TexCoord2((float)ClientRectangle.Width / TextureBackground.Width, 0);
    GL.Vertex2(ProjectionWidth, 0);

    GL.TexCoord2((float)ClientRectangle.Width / TextureBackground.Width, (float)ClientRectangle.Height / TextureBackground.Height);
    GL.Vertex2(ProjectionWidth, ProjectionHeight);

    GL.TexCoord2(0, (float)ClientRectangle.Height / TextureBackground.Height);
    GL.Vertex2(0, ProjectionHeight);

    GL.End();
}

private void RenderSolid(float X, float Y, int Color) {
    ColorTextures[Color].Bind();
    GL.Color4(Color4.White);
    GL.Begin(BeginMode.Quads);

    GL.TexCoord2(0, 0);
    GL.Vertex2(X * SolidSize, Y * SolidSize);

    GL.TexCoord2(1, 0);
    GL.Vertex2((X + 1) * SolidSize, Y * SolidSize);

    GL.TexCoord2(1, 1);
    GL.Vertex2((X + 1) * SolidSize, (Y + 1) * SolidSize);

    GL.TexCoord2(0, 1);
    GL.Vertex2(X * SolidSize, (Y + 1) * SolidSize);

    GL.End();
}


Заданный цвет как бы окрашивает, тонирует текстуру, поэтому указываем белый цвет.

Прекрасно, текстуры работают. Коммитим: «Textured background and solids».

Main Street


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

private void RenderPipe() {
    GL.Disable(EnableCap.Texture2D);
    GL.Color4(Color4.Black);

    GL.Begin(BeginMode.Quads);
    GL.Vertex2(0, 0);
    GL.Vertex2(MapWidth * SolidSize, 0);
    GL.Vertex2(MapWidth * SolidSize, MapHeight * SolidSize);
    GL.Vertex2(0, MapHeight * SolidSize);
    GL.End();

    GL.Enable(EnableCap.Texture2D);
}

protected override void OnRenderFrame(FrameEventArgs E) {
    // . . .

    RenderBackground();

    RenderPipe();

    // . . .
}


Спозиционируем стакан. Пусть он находится слева, с небольшим отступом от краёв окна. Справа от стакана позже разместим дополнительные элементы интерфейса (счёт и т. д.). Однако если окно будет растянуто по ширине, то пусть стакан выдвигается вправо по направлению к центру окна, иначе справа будет слишком много пустого пространства. Напоследок запретим делать окно меньше NominalWidth × NominalHeight (это, правда, не будет работать под X window system).

private const int NominalWidth = 500;
private const int NominalHeight = 500;

protected override void OnResize(EventArgs E) {
    // . . .

    if (ClientSize.Width < NominalWidth) {
        ClientSize = new Size(NominalWidth, ClientSize.Height);
    }
    if (ClientSize.Height < NominalHeight) {
        ClientSize = new Size(ClientSize.Width, NominalHeight);
    }
}

protected override void OnRenderFrame(FrameEventArgs E) {
    // . . .

    RenderBackground();

    var PipeMarginY = (ProjectionHeight - MapHeight * SolidSize) / 2f;
    var PipeMarginX = (NominalHeight - MapHeight * SolidSize) / 2f;

    var Overwidth = ProjectionWidth - ProjectionHeight * (float)NominalWidth / NominalHeight;
    if (Overwidth > 0) {
        GL.Translate(Math.Min(Overwidth, (ProjectionWidth - MapWidth * SolidSize) / 2f), PipeMarginY, 0);
    } else {
        GL.Translate(PipeMarginX, PipeMarginY, 0);
    }

    RenderPipe();

    // . . .
}


Коммитим: «Position and render pipe».

Writing on the Wall


Что же будет справа от стакана? Четыре элемента: следующая палка; статус игры (там будет сообщение «Playing», «Paused» или «Game Over»); текущий счёт; рекордный счёт.

Большинство из этого — текст, соответственно, нам нужно научиться выводить текст средствами OpenGL. Сама OpenGL ничего такого не умеет. Часто встречаются упоминания о том, будто в OpenTK есть для этой цели удобный класс TextPrinter. Это было давно и неправда. Сейчас рекомендуемым методом отображения текста является следующий: сделать bitmap с текстом (средствами System.Drawing.Graphics.DrawString или др.) и натянуть её как текстуру.

Напишем свой класс TextRenderer, который будет создавать Bitmap и затем Texture на её основе. Но сначала придётся озаботиться вышеупомянутой проблемой NPOT-размерных текстур, поскольку мы не знаем наперёд, какой размер получится у динамически создаваемой надписи. Метод довольно простой: если NPOT-текстуры не поддерживаются, то надо при загрузке картинки делать POT-размерную текстуру, как бы с полями. Например, если загружаем картинку 300×200, то генерируем текстуру 512×256, на которой в левом верхнем углу будет наша картинка, а остальное пространство будет пустовать. И при накладывании текстуры необходимо будет учесть, что левый нижний угол картинки имеет координаты не (1; 1), а (300/512; 200/256).

public class Texture : IDisposable {
    public int GlHandle { get; protected set; }

    public int Width { get; protected set; }
    public int Height { get; protected set; }

    #region NPOT

    private static bool? CalculatedSupportForNpot;
    public static bool NpotIsSupported {
        get {
            if (!CalculatedSupportForNpot.HasValue) {
                CalculatedSupportForNpot = false;
                int ExtensionsCount;
                GL.GetInteger(GetPName.NumExtensions, out ExtensionsCount);
                for (var i = 0; i < ExtensionsCount; i++) {
                    if ("GL_ARB_texture_non_power_of_two" == GL.GetString(StringName.Extensions, i)) {
                        CalculatedSupportForNpot = true;
                        break;
                    }
                }
            }
            return CalculatedSupportForNpot.Value;
        }
    }

    public int PotWidth {
        get {
            return NpotIsSupported ? Width : (int)Math.Pow(2, Math.Ceiling(Math.Log(Width, 2)));
        }
    }
    public int PotHeight {
        get {
            return NpotIsSupported ? Height : (int)Math.Pow(2, Math.Ceiling(Math.Log(Height, 2)));
        }
    }

    #endregion

    public Texture(Bitmap Bitmap) {
        // . . .

        var BitmapData = Bitmap.LockBits(new Rectangle(0, 0, Bitmap.Width, Bitmap.Height), ImageLockMode.ReadOnly, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
        GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba, PotWidth, PotHeight, 0, OpenTK.Graphics.OpenGL.PixelFormat.Bgra, PixelType.UnsignedByte, IntPtr.Zero);
        GL.TexSubImage2D(TextureTarget.Texture2D, 0, 0, 0, BitmapData.Width, BitmapData.Height, OpenTK.Graphics.OpenGL.PixelFormat.Bgra, PixelType.UnsignedByte, BitmapData.Scan0);
        Bitmap.UnlockBits(BitmapData);

        // . . .
    }

    // . . .
}


Теперь — TextRenderer. Код кажется длинным, но на самом деле всё просто. Здесь делаем четыре основные вещи: задаём параметры текста, измеряем его размеры, отрисовываем текст в текстуру и, наконец, накладываем текстуру на прозрачный прямоугольник.

using System;
using System.Drawing;
using System.Drawing.Text;
using OpenTK.Graphics;
using OpenTK.Graphics.OpenGL;

namespace ImpressiveSolids {
    class TextRenderer {
        private Font FontValue;
        private string LabelValue;
        private bool NeedToCalculateSize, NeedToRenderTexture;
        private Texture Texture;
        private int CalculatedWidth, CalculatedHeight;

        public Font Font {
            get {
                return FontValue;
            }

            set {
                FontValue = value;
                NeedToCalculateSize = true;
                NeedToRenderTexture = true;
            }
        }

        public string Label {
            get {
                return LabelValue;
            }

            set {
                if (value != LabelValue) {
                    LabelValue = value;
                    NeedToCalculateSize = true;
                    NeedToRenderTexture = true;
                }
            }
        }

        public int Width {
            get {
                if (NeedToCalculateSize) {
                    CalculateSize();
                }
                return CalculatedWidth;
            }
        }

        public int Height {
            get {
                if (NeedToCalculateSize) {
                    CalculateSize();
                }
                return CalculatedHeight;
            }
        }

        public Color4 Color = Color4.Black;

        public TextRenderer(Font Font) {
            this.Font = Font;
        }

        public TextRenderer(Font Font, Color4 Color) {
            this.Font = Font;
            this.Color = Color;
        }

        public TextRenderer(Font Font, string Label) {
            this.Font = Font;
            this.Label = Label;
        }

        public TextRenderer(Font Font, Color4 Color, string Label) {
            this.Font = Font;
            this.Color = Color;
            this.Label = Label;
        }

        private void CalculateSize() {
            using (var Bitmap = new Bitmap(1, 1)) {
                using (Graphics Graphics = Graphics.FromImage(Bitmap)) {
                    var Measures = Graphics.MeasureString(Label, Font);
                    CalculatedWidth = (int)Math.Ceiling(Measures.Width);
                    CalculatedHeight = (int)Math.Ceiling(Measures.Height);
                }
            }
            NeedToCalculateSize = false;
        }

        public void Render() {
            if ((null == Label) || ("" == Label)) {
                return;
            }

            if (NeedToRenderTexture) {
                using (var Bitmap = new Bitmap(Width, Height)) {
                    var Rectangle = new Rectangle(0, 0, Bitmap.Width, Bitmap.Height);
                    using (Graphics Graphics = Graphics.FromImage(Bitmap)) {
                        Graphics.Clear(System.Drawing.Color.Transparent);
                        Graphics.TextRenderingHint = TextRenderingHint.AntiAliasGridFit;
                        Graphics.DrawString(Label, Font, Brushes.White, Rectangle);

                        if (null != Texture) {
                            Texture.Dispose();
                        }
                        Texture = new Texture(Bitmap);
                    }
                }
                NeedToRenderTexture = false;
            }

            Texture.Bind();

            GL.Color4(Color);

            GL.Begin(BeginMode.Quads);

            GL.TexCoord2(0, 0);
            GL.Vertex2(0, 0);

            GL.TexCoord2((float)Texture.Width / Texture.PotWidth, 0);
            GL.Vertex2(Width, 0);

            GL.TexCoord2((float)Texture.Width / Texture.PotWidth, (float)Texture.Height / Texture.PotHeight);
            GL.Vertex2(Width, Height);

            GL.TexCoord2(0, (float)Texture.Height / Texture.PotHeight);
            GL.Vertex2(0, Height);

            GL.End();
        }
    }
}


Выведем кое-что справа от стакана.

using System.Drawing.Text;

// . . .

private int Score;
private int HighScore;

private TextRenderer NextStickLabel, ScoreLabel, ScoreRenderer, HighScoreLabel, HighScoreRenderer, GameOverLabel, GameOverHint;

public Game()
    // . . .

    var LabelFont = new Font(new FontFamily(GenericFontFamilies.SansSerif), 20, GraphicsUnit.Pixel);
    var LabelColor = Color4.SteelBlue;
    NextStickLabel = new TextRenderer(LabelFont, LabelColor, "Next");
    ScoreLabel = new TextRenderer(LabelFont, LabelColor, "Score");
    HighScoreLabel = new TextRenderer(LabelFont, LabelColor, "High score");

    var ScoreFont = new Font(new FontFamily(GenericFontFamilies.SansSerif), 50, GraphicsUnit.Pixel);
    var ScoreColor = Color4.Tomato;
    ScoreRenderer = new TextRenderer(ScoreFont, ScoreColor);
    HighScoreRenderer = new TextRenderer(ScoreFont, ScoreColor);

    var GameStateFont = new Font(new FontFamily(GenericFontFamilies.SansSerif), 30, GraphicsUnit.Pixel);
    var GameStateColor = Color4.Tomato;
    GameOverLabel = new TextRenderer(GameStateFont, GameStateColor, "Game over");

    var GameStateHintFont = new Font(new FontFamily(GenericFontFamilies.SansSerif), 25, GraphicsUnit.Pixel);
    var GameStateHintColor = Color4.SteelBlue;
    GameOverHint = new TextRenderer(GameStateHintFont, GameStateHintColor, "Press Enter");
}

protected override void OnRenderFrame(FrameEventArgs E) {
    // . . .

    GL.Translate(MapWidth * SolidSize + PipeMarginX, 0, 0);

    NextStickLabel.Render();
    // TODO вывести собственно next stick

    GL.Translate(0, MapHeight * SolidSize / 4f, 0);
    if (GameStateEnum.GameOver == GameState) {
        GameOverLabel.Render();
        GL.Translate(0, GameOverLabel.Height, 0);
        GameOverHint.Render();
        GL.Translate(0, -GameOverLabel.Height, 0);
    }

    GL.Translate(0, MapHeight * SolidSize / 4f, 0);
    ScoreLabel.Render();
    GL.Translate(0, ScoreLabel.Height, 0);
    ScoreRenderer.Label = Score.ToString();
    ScoreRenderer.Render();
    GL.Translate(0, -ScoreLabel.Height, 0);

    GL.Translate(0, MapHeight * SolidSize / 4f, 0);
    HighScoreLabel.Render();
    GL.Translate(0, HighScoreLabel.Height, 0);
    HighScoreRenderer.Label = HighScore.ToString();
    HighScoreRenderer.Render();

    SwapBuffers();
}


MapHeight * SolidSize / 4f — это четверть высоты стакана, мы каждый раз спускаемся ниже на это расстояние, чтобы изобразить один из четырёх элементов интерфейса. Кроме того, выведя надпись, мы спускаемся ниже на её высоту, а затем не забываем подняться обратно к исходной точке.

Коммитим: «Text GUI».

Next


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

private int[] NextStickColors;

private void GenerateNextStick() {
    for (var i = 0; i < StickLength; i++) {
        StickColors[i] = NextStickColors[i];
        NextStickColors[i] = Rand.Next(ColorsCount);
    }
    StickPosition.X = (float)Math.Floor((MapWidth - StickLength) / 2d);
    StickPosition.Y = 0;
}

private void New() {
    // . . .

    StickColors = new int[StickLength];
    NextStickColors = new int[StickLength];
    GenerateNextStick();
    GenerateNextStick(); // because 1st call makes current stick all zeros
    GameState = GameStateEnum.Fall;
}


Для отображения воспользуемся методом RenderSolid, всё очень просто.

protected override void OnRenderFrame(FrameEventArgs E) {
    // . . .

    NextStickLabel.Render();
    GL.Translate(0, NextStickLabel.Height, 0);
    RenderNextStick();
    GL.Translate(0, -NextStickLabel.Height, 0);

    // . . .
}

public void RenderNextStick() {
    GL.Disable(EnableCap.Texture2D);
    GL.Color4(Color4.Black);

    GL.Begin(BeginMode.Quads);
    GL.Vertex2(0, 0);
    GL.Vertex2(StickLength * SolidSize, 0);
    GL.Vertex2(StickLength * SolidSize, SolidSize);
    GL.Vertex2(0, SolidSize);
    GL.End();

    GL.Enable(EnableCap.Texture2D);

    for (var i = 0; i < StickLength; i++) {
        RenderSolid(i, 0, NextStickColors[i]);
    }
}


Готово, коммитим: «Render next stick».

The Score


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

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

private int TotalDestroyedThisMove;

private void New() {
    // . . .

    Score = 0;
    TotalDestroyedThisMove = 0;
}

protected override void OnUpdateFrame(FrameEventArgs E) {
    // . . .

    if (Destroyables.Count > 0) {
        foreach (var Coords in Destroyables) {
            Map[(int)Coords.X, (int)Coords.Y] = -1;
        }
        Score += (int)Math.Ceiling(Destroyables.Count + Math.Pow(1.5, Destroyables.Count - 3) - 1) + TotalDestroyedThisMove;
        TotalDestroyedThisMove += Destroyables.Count;
        Stabilized = false;
    }

    // . . .

    GenerateNextStick();
    TotalDestroyedThisMove = 0;
    GameState = GameStateEnum.Fall;

    // . . .
}


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

using System.IO;

// . . .

private string HighScoreFilename;

public Game() {
    // . . .

    var ConfigDirectory = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) + Path.DirectorySeparatorChar + "ImpressiveSolids";
    if (!Directory.Exists(ConfigDirectory)) {
        Directory.CreateDirectory(ConfigDirectory);
    }

    HighScoreFilename = ConfigDirectory + Path.DirectorySeparatorChar + "HighScore.dat";
    if (File.Exists(HighScoreFilename)) {
        using (var Stream = new FileStream(HighScoreFilename, FileMode.Open)) {
            using (var Reader = new BinaryReader(Stream)) {
                try {
                    HighScore = Reader.ReadInt32();
                } catch (IOException) {
                    HighScore = 0;
                }
            }
        }
    } else {
        HighScore = 0;
    }
}

protected override void OnUpdateFrame(FrameEventArgs E) {
    // . . .

    if (GameOver) {
        GameState = GameStateEnum.GameOver;

        if (Score > HighScore) {
            HighScore = Score;
            using (var Stream = new FileStream(HighScoreFilename, FileMode.Create)) {
                using (var Writer = new BinaryWriter(Stream)) {
                    Writer.Write(HighScore);
                }
            }
        }
    } else {
    // . . .
}


Коммитим: «Calculating score, storing high score».

Heaven Can Wait


Напоследок сделаем возможность ставить игру на паузу.

Паузу не получится сделать как состояние (GameStateEnum), потому что на паузу можно ставить игру как во время падения палки (Fall), так и во время Impact, а из паузы игра должна возвращаться обратно в то состояние, в котором была.

Поэтому введём дополнительный флаг Paused и его обработку в OnUpdateFrame, OnKeyDown, OnRenderFrame.

private bool Paused;

private TextRenderer PauseLabel, UnpauseHint, PlayingGameLabel, PauseHint;

public Game()
    // . . .

    var GameStateFont = new Font(new FontFamily(GenericFontFamilies.SansSerif), 30, GraphicsUnit.Pixel);
    var GameStateColor = Color4.Tomato;
    GameOverLabel = new TextRenderer(GameStateFont, GameStateColor, "Game over");
    PauseLabel = new TextRenderer(GameStateFont, GameStateColor, "Pause");
    PlayingGameLabel = new TextRenderer(GameStateFont, GameStateColor, "Playing");

    var GameStateHintFont = new Font(new FontFamily(GenericFontFamilies.SansSerif), 25, GraphicsUnit.Pixel);
    var GameStateHintColor = Color4.SteelBlue;
    GameOverHint = new TextRenderer(GameStateHintFont, GameStateHintColor, "Press Enter");
    UnpauseHint = new TextRenderer(GameStateHintFont, GameStateHintColor, "Press Space");
    PauseHint = new TextRenderer(GameStateHintFont, GameStateHintColor, "Space pauses");
}

protected override void OnLoad(EventArgs E) {
    base.OnLoad(E);

    GL.Enable(EnableCap.Texture2D);
    GL.Enable(EnableCap.Blend);

    GL.BlendFunc(BlendingFactorSrc.SrcAlpha, BlendingFactorDest.OneMinusSrcAlpha);

    New();
    Paused = true;
}

protected override void OnUpdateFrame(FrameEventArgs E) {
    base.OnUpdateFrame(E);

    if (Paused) {
        return;
    }

    // . . .
}

protected void OnKeyDown(object Sender, KeyboardKeyEventArgs E) {
    if ((GameStateEnum.Fall == GameState) && !Paused) {
        // . . .
    }

    if (((GameStateEnum.Fall == GameState) || (GameStateEnum.Impact == GameState)) && (Key.Space == E.Key)) {
        Paused = !Paused;
    }
}

protected override void OnRenderFrame(FrameEventArgs E) {
    // . . .

    GL.Translate(0, MapHeight * SolidSize / 4f, 0);
    if (GameStateEnum.GameOver == GameState) {
        GameOverLabel.Render();
        GL.Translate(0, GameOverLabel.Height, 0);
        GameOverHint.Render();
        GL.Translate(0, -GameOverLabel.Height, 0);
    } else if (Paused) {
        PauseLabel.Render();
        GL.Translate(0, PauseLabel.Height, 0);
        UnpauseHint.Render();
        GL.Translate(0, -PauseLabel.Height, 0);
    } else {
        PlayingGameLabel.Render();
        GL.Translate(0, PlayingGameLabel.Height, 0);
        PauseHint.Render();
        GL.Translate(0, -PlayingGameLabel.Height, 0);
    }

    // . . .
}


Новую игру при запуске приложения удобно (для игрока) начинать как раз в состоянии паузы.

Используем пробел, а не клавишу Pause, потому что пробел расположен удобнее и о нём всегда помнят, в отличие от Pause, про которую многие даже и не знают. Кроме того, с последней возникают проблемы, в частности, при использовании Punto Switcher.

Коммитим: «Pause».

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

Проект доступен на Google Project Hosting Bitbucket, там можно посмотреть итоговый исходный код, скачать архив с готовым к запуску исполняемым файлом.
Теги:.netигрыразработка игрgame developmentopenglopentkcsharp
Хабы: Разработка игр
Всего голосов 31: ↑25 и ↓6 +19
Просмотры13.8K

Комментарии 21

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

Похожие публикации

Разработчик игр Unity Middle мобильное приложение для малышей
от 90 000 до 200 000 ₽Engligh Gym KidsМожно удаленно
Middle Game designer
до 2 000 $Stark GamesМинскМожно удаленно
Unity developer (middle)
от 80 000 до 100 000 ₽PiRL VenturesСанкт-ПетербургМожно удаленно
Unity3D Developer
от 110 000 ₽Game InsightМожно удаленно
Программист Unity в проект Outfire (шутер)
от 110 000 ₽MYTONAСанкт-ПетербургМожно удаленно

Лучшие публикации за сутки