В первой части разработки тетрисоподобной игры Impressive Solids мы реализовали основную часть геймплея, уделив минимальное внимание внешнему виду приложения. Мы и OpenGL-то почти не использовали, всего и делали, что рисовали цветной прямоугольник. Пришла пора заняться оформлением, а также реализовать подсчёт очков и хранение рекорда (high score). Ну что, поехали дальше.
Займёмся текстурами. Нам нужно, во-первых, натянуть что-нибудь на фон окна, а во-вторых, сделать приятно выглядящие блоки (сейчас это просто цветные прямоугольники). Понятное дело, сначала надо изготовить текстуры. В этом нам поможет 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 не оперирует графическими файлами, нужно своими средствами (в этом поможет
Инкапсулируем этот механизм в виде нового класса
В
Изменим рендеринг. Надо включить текстуры в целом, а также режим прозрачности. Затем перед тем, как задать координаты прямоугольника, надо выбрать (bind) соответствующую текстуру. Перед каждой точкой (vertex) надо задать соответствующие координаты текстуры (считается, что (1; 1) — правый нижний угол текстуры).
Заданный цвет как бы окрашивает, тонирует текстуру, поэтому указываем белый цвет.
Прекрасно, текстуры работают. Коммитим: «Textured background and solids».
Теперь надо обозначить стакан. Пусть это будет просто чёрный прямоугольник, поверх фона окна, но за блоками.
Спозиционируем стакан. Пусть он находится слева, с небольшим отступом от краёв окна. Справа от стакана позже разместим дополнительные элементы интерфейса (счёт и т. д.). Однако если окно будет растянуто по ширине, то пусть стакан выдвигается вправо по направлению к центру окна, иначе справа будет слишком много пустого пространства. Напоследок запретим делать окно меньше
Коммитим: «Position and render pipe».
Что же будет справа от стакана? Четыре элемента: следующая палка; статус игры (там будет сообщение «Playing», «Paused» или «Game Over»); текущий счёт; рекордный счёт.
Большинство из этого — текст, соответственно, нам нужно научиться выводить текст средствами OpenGL. Сама OpenGL ничего такого не умеет. Часто встречаются упоминания о том, будто в OpenTK есть для этой цели удобный класс
Напишем свой класс
Теперь —
Выведем кое-что справа от стакана.
Коммитим: «Text GUI».
Отобразим собственно следующую палку. Для начала, правда, надо немного изменить модель, потому что сейчас у нас следующая палка генерируется в момент начала следующего хода, а надо, чтобы генерировалась уже на текущем ходу и где-то хранилась.
Для отображения воспользуемся методом
Готово, коммитим: «Render next stick».
Займёмся подсчётом очков. Надо давать больше очков за длинные линии, за одновременное уничтожение нескольких линий, за последовательное уничтожение нескольких линий в рамках одного хода. Это заинтересует игроков строить сложные комбинации, добавит игре интереса.
Формулы ниже выведены навскидку, их, конечно, надо будет ещё проверить на этапе бета-тестирования, посмотреть на отзывы игроков.
По окончанию игры будем обновлять рекорд (если он побит) и записывать в файл, а при запуске приложения — читать текущий рекорд из файла.
Коммитим: «Calculating score, storing high score».
Напоследок сделаем возможность ставить игру на паузу.
Паузу не получится сделать как состояние (
Поэтому введём дополнительный флаг
Новую игру при запуске приложения удобно (для игрока) начинать как раз в состоянии паузы.
Используем пробел, а не клавишу Pause, потому что пробел расположен удобнее и о нём всегда помнят, в отличие от Pause, про которую многие даже и не знают. Кроме того, с последней возникают проблемы, в частности, при использовании Punto Switcher.
Коммитим: «Pause».
На этом пока всё. Несомненно, в игре ещё можно доделать много мелких нюансов и частностей. Наверняка всплывут баги, которые нужно будет исправить. Всё это я оставляю читателю на самостоятельное изучение.
Проект доступен наGoogle Project Hosting Bitbucket, там можно посмотреть итоговый исходный код, скачать архив с готовым к запуску исполняемым файлом.
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».
На этом пока всё. Несомненно, в игре ещё можно доделать много мелких нюансов и частностей. Наверняка всплывут баги, которые нужно будет исправить. Всё это я оставляю читателю на самостоятельное изучение.
Проект доступен на