Создано при помощи нейросети Kandinsky

Пишем 2D-игру в стиле Arkanoid на C#

Разработка на C# Просмотров: 2740

User Rating: 0 / 5

Изображение к статье создано при помощи нейросети Kandinsky

Всем привет.

В сегодняшней статье мы напишем с вами игру в стиле Arkanoid, используя для этого язык C#.

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

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

В конце данной статьи-урока вы найдёте ссылку на архив с готовым решением и разработанной игрой.

Итак, давайте приступим к созданию игры.

Часть 1. Требования и ограничения к игре. Геймдизайн.

Сначала мы посмотрим на то, что будет представлять из себя разрабатываемая игра в стиле Arkanoid и какие требования/ограничения мы для неё выставим.

Требования к игре и основной геймдизайн:
Ограничения к игре:

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

Таким образом будет выглядеть игровой процесс:

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

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

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

Часть 2. Архитектура игры и её составные компоненты

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

"Позиция игрового объекта" (Класс GameObjectPosition)

Для реализации логики игры нам потребуется хранить местоположение различных игровых объектов (проще говоря, их координаты) на игровом поле. Класс GameObjectPosition как раз предназначен для того, чтобы хранить в себе необходимую информации о местоположении того или иного игрового объекта на игровом поле.

"Игровой объект" (Абстрактный класс GameObject)

Одним из классов верхнего уровня, которые мы создадим для игры, будет являться абстрактный класс "игровой объект" - GameObject. Этот класс будет описывать некоторый абстрактный игровой объект, который может встречаться в нашей игре и может быть каким-либо образом нарисован на игровом поле. Мы предусмотрим следующие типы игровых объектов:

Info icon by Icons8НА ЗАМЕТКУ
При желании и соответствующем интересе вы сможете впоследствии задать и разработать собственные типы игровых объектов, улучшив геймплей на свой вкус. В самом конце статьи я приведу некоторые возможные варианты улучшения игры.

"Направление движения" (Интерфейс IMovingDirection и его интерфейсы-наследники)

Для описания направления движения игровых объектов мы определим отдельный интерфейс IMovingDirection и несколько других интерфейсов, которые будут являться его наследниками:

"Простое диагональное движение" (Класс SimpleDiagonalMovingDirection, реализующий интерфейс IDiagonalMovingDirection)

Так как "направление движения" мы выразили концептуально через интерфейс IMovingDirection, описанный выше, то нам потребуются классы, реализующие этот интерфейс и его наследников. Для цели игры нам нужно будет иметь класс, реализующий дочерний для IMovingDirection интерфейс - IDiagonalMovingDirection. И этот класс мы назовём SimpleDiagonalMovingDirection. По экземпляру этого класса наш летающий по полю шарик будет понимать, в каком именно направлении он сейчас движется.

"Движущийся игровой объект" (Интерфейс IMovingGameObject и его наследники)

Поскольку в нашей игре встречаются движущиеся игровые объекты, например, летающий по полю шарик, то мы создадим универсальный интерфейс с именем IMovingGameObject<T>, который будет описывать некоторый абстрактный движущийся игровой объект в игре. Параметр типа T для интерфейса будет являться интерфейсом IMovingDirection или же его подтипом (т.е. одним из интерфейсов-наследников или классом, реализующим интерфейс).

Несмотря на то, что платформа игрока у нас также будет двигаться, её движение всё же имеет иной характер - движение платформы игрока осуществляется за счёт управления игроком при помощи мыши. Интерфейс IMovingGameObject<T> же будет предназначен для таких объектов, которые имеют заданную траекторию (или направление) движения и могут перемещаться по полю самостоятельно.

Также другие интерфейсы-наследники от IMovingGameObject:

"Позиция стены" (Перечисляемый тип WallPosition)

В процессе реализации логики движения и отскока летающего по полю шарика нам будет нужно понимать, с какой стороны находится стена. Для этой цели мы введём в игру enum-тип с именем WallPosition, который будет иметь всего 5 значений:

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

"Отрисовщик игровых объектов" (Класс GameObjectsRenderer)

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

"Летающий по полю и отскакивающий шарик" (Класс BouncingBall)

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

"Платформа игрока" (Класс RectangularGameObject)

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

"Статичный блок" (Класс StaticBlock)

В игре будут встречаться игровые блоки, которые игрок разбивает шариком, поэтому за эти блоки будет отвечать класс StaticBlock. Поскольку блоки в игре также имеют прямоугольную форму, этот класс будет являться наследником от класса RectangularGameObject.

"Игровой движок" (Класс GameEngine) 

В нашей игре нам нужен некоторый класс, который бы управлял игровым процессом, оперируя игровыми объектами, изменяя их состояние и проверяя текущее состояние самой игры. Для этой цели мы введём в игру класс игрового движка и назовём его, соответственно, GameEngine.

"Игровая статистика" (Класс GameStats)

В игре мы будем вести счёт (количество побед игрока, количество поражений, количество отбитых игроком шариков и так далее). Для того, чтобы управлять всеми показателями игры (игровой статистикой), мы введём отдельный класс игровой статистики и назовём его GameStats. Класс будет поддерживать словарь для целочисленных "счётчиков" (или показателей игры), а также удобные методы по управлению игровыми показателями.

Ниже представлена UML-диаграмма с описанными классами и интерфейсами, которые мы создадим для игры:

ArkanoidGame_Class_Diagram.png

Часть 3. Создание нового проекта для игры и его настройка

В среде разработки Microsoft Visual Studio необходимо создать новый проект с типом "Приложение Windows Forms (.NET Framework)". В качестве имени для проекта выбираем ArkanoidGameExample. Местоположение проекта выберете на свой вкус, я оставляю обычно настройки по умолчанию.

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

Главной форме установим следующие свойства:

Теперь сохраним все сделанные изменения со свойствами формой и проектом, нажав комбинацию клавиш Ctrl+S.

Следующим шагом в окне "Обозреватель решений" (обычно оно расположено сверху-справа) добавим в проект ArkanoidGameExample новые каталоги (сперва они будут пустыми, затем добавим в них классы и интерфейсы, что были описаны выше), чтобы получилось следующая иерархия каталогов:

Теперь мы готовы к созданию интерфейсов и классов для нашей будущей игры.

Часть 4. Пишем код для интерфейсов и классов игры

Часть 4.1. Пространство имён ArkanoidGameExample.GameObjects.Positioning

Начнём мы с написания класса для описания позиции игровых объектов в игре. Создадим в каталоге GameObjects/Positioning новый класс с именем GameObjectPosition. Поскольку мы создаём класс внутри каталога, для него автоматически будет назначено соответствующее пространство имён - ArkanoidGameExample.GameObjects.Positioning. Здесь и далее мы не будем менять эти пространства имён, они должны совпадать с иерархией ранее созданных каталогов.

Ниже представлен полный код класса GameObjectPosition с единственным использованным им пространством имён System.Drawing:

using System.Drawing;

namespace ArkanoidGameExample.GameObjects.Positioning {
    /// <summary>
    /// Позиция игрового объекта
    /// </summary>
    public class GameObjectPosition {
        private Point position;

        public Point Position {
            get {
                return position;
            }
        }
        
        public int X {
            get {
                return position == Point.Empty ? 0 : position.X;
            }
            set {
                CreateNewPositionIfEmpty();
                position.X = value;
            }
        }

        public int Y {
            get {
                return position == Point.Empty ? 0 : position.Y;
            }
            set {
                CreateNewPositionIfEmpty();
                position.Y = value;                
            }
        }

        private void CreateNewPositionIfEmpty() {
            if (position == Point.Empty) {
                CreateNewPosition();
            }
        }

        private void CreateNewPosition() {
            position = new Point();
        }

        public void SetPosition(Point point) {
            CreateNewPositionIfEmpty();
            position.X = point.X;
            position.Y = point.Y;
        }

        public void SetPosition(int x, int y) {
            CreateNewPositionIfEmpty();
            position.X = x;
            position.Y = y;
        }
    }
}

Можно заметить, что в этом классе есть единственное закрытое (private) поле position, тип данных которого - Point. Это стандартная структура, доступная в .NET Framework и уже умеющая хранить пару X и Y для представления координат некоторой точки. В целом, можно было бы обойтись и без этого класса и использовать для хранения всех игровых объектов непосредственно имеющуюся структуру Point. Однако я решил ввести отдельный класс GameObjectPosition, и инкапсулировать в него поле position. Класс имеет пару публичных методов (SetPosition и его перегруженный вариант) для установки позиции (местоположения) игрового объекта на игровом поле, а также закрытые методы CreateNewPosition, CreateNewPositionIfEmpty, которые просто инициализируют нужным образом поле position. Свой отдельный класс может быть удобен, если в дальнейшем мы захотим наделить его какими-то иными полями/свойствами/методами, которые нам потребуются для развития игры.

Следующим шагом в этом же каталоге GameObjects/Positioning мы создадим перечисляемый тип WallPosition. Для этого просто создаём обычный класс с именем файла WallPosition.cs, а затем класс поменяем на enum-тип. Вот его полный код:

namespace ArkanoidGameExample.GameObjects.Positioning {
    /// <summary>
    /// Указывает, в с какой стороны игровой объект (шарик) встретил "стену"
    /// </summary>
    public enum WallPosition {
        NoWall,
        WallFromTheLeft,
        WallFromTheRight,
        WallFromTheTop,
        WallFromTheBottom
    }
}

Описание значений этого enum-типа:

Напомню, под "стеной" в нашей игре мы понимаем фактически границы игрового поля, т.е., проще говоря, границы главной формы приложения. Как видим, enum-тип принадлежит этому же пространству имён ArkanoidGameExample.GameObjects.Positioning. Понимание того, с какой стороны находится стена будет в дальнейшем необходимо двигающемуся шарику, поскольку он должен понимать, пора ли "отскочить" от стены или ещё нет.

Часть 4.2. Пространство имён ArkanoidGameExample.GameObjects.MovingDirection

Теперь мы создадим различные интерфейсы, описывающие специфику направления движения игровых объектов, внутри каталога GameObjects/MovingDirection.

Первый создаваемый интерфейс - это IMovingDirection, он очень компактный и содержит всего 3 метода:

namespace ArkanoidGameExample.GameObjects.MovingDirection {
    public interface IMovingDirection {
        bool IsNotMoving();

        void InitRandomDirection();

        void InitRandomSafeDirection();
    }
}

Описание назначения методов этого интерфейса:

Второй интерфейс, который мы создадим - это IVerticalMovingDirection. Создаём обычный класс с именем файла IVerticalMovingDirection.cs и заменяем всё его содержимое на следующий код:

namespace ArkanoidGameExample.GameObjects.MovingDirection {
    public interface IVerticalMovingDirection : IMovingDirection {
        bool IsMovingUp();
        bool IsMovingDown();
    }
}

Он наследуется от первого интерфейса и добавляет к его поведению два других метода:

Следующий интерфейс, который мы создадим, - это IHorizontalMovingDirection. Нетрудно догадаться, что он аналогичен предыдущему, только описывает горизонтально движущиеся объекты в игре. Вот его код:

namespace ArkanoidGameExample.GameObjects.MovingDirection {
    public interface IHorizontalMovingDirection : IMovingDirection {
        bool IsMovingLeft();
        bool IsMovingRight();
    }
}

Обратите внимание, что он также является наследником интерфейса IMovingDirection, следовательно, наследует описанные в нём методы.

Два его метода, думаю, понятны, но на всякий случай опишу их:

Следующий интерфейс - это IHorizontalAndVerticalMovingDirection, он будет пустым и просто наследуется от двух ранее созданных:

namespace ArkanoidGameExample.GameObjects.MovingDirection {
    public interface IHorizontalAndVerticalMovingDirection : IHorizontalMovingDirection, IVerticalMovingDirection {
    }
}

Интерфейс может предназначаться для каких-либо игровых объектов, которые умеют двигаться и по горизонтали, и по вертикали. Также напомню, что все три интерфейса выше IVerticalMovingDirection, IHorizontalMovingDirection и IHorizontalAndVerticalMovingDirection в игре использоваться не будут. Я подготовил их "про запас" - для тех читателей, которые захотят поэкспериментировать и улучшить игру в дальнейшем, введя в неё какие-то объекты, реализующие один из этих интерфейсов.

Следующий интерфейс уже будет использоваться в нашей игре. Это интерфейс IDiagonalMovingDirection, создадим его и напишем в нём следующий код:

namespace ArkanoidGameExample.GameObjects.MovingDirection {
    public interface IDiagonalMovingDirection : IMovingDirection {
        bool IsMovingUpRight();
        bool IsMovingUpLeft();
        bool IsMovingDownRight();
        bool IsMovingDownLeft();

        void ChangeDirectionToUpLeft();
        void ChangeDirectionToUpRight();
        void ChangeDirectionToDownLeft();
        void ChangeDirectionToDownRight();
    }
}

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

Описание его методов:

И последнее, что мы создадим в каталоге GameObjects/MovingDirection, будет класс, реализующий только что созданный интерфейс IDiagonalMovingDirection.

Этот класс мы назовём SimpleDiagonalMovingDirection. Ниже представлен его код:

using System;

namespace ArkanoidGameExample.GameObjects.MovingDirection {
    public class SimpleDiagonalMovingDirection : IDiagonalMovingDirection {
        private DiagonalMovingDirection currentDirection;
        private Random random;

        public enum DiagonalMovingDirection {
            IsNotMoving,
            MovingUpLeft,
            MovingUpRight,
            MovingDownLeft,
            MovingDownRight
        }

        public SimpleDiagonalMovingDirection(DiagonalMovingDirection currentDirection) : this() {
            this.currentDirection = currentDirection;
        }

        public SimpleDiagonalMovingDirection() {
            currentDirection = DiagonalMovingDirection.IsNotMoving;
            random = new Random();
        }


        public bool IsMovingDownLeft() {
            return currentDirection == DiagonalMovingDirection.MovingDownLeft;
        }

        public bool IsMovingDownRight() {
            return currentDirection == DiagonalMovingDirection.MovingDownRight;
        }

        public bool IsMovingUpLeft() {
            return currentDirection == DiagonalMovingDirection.MovingUpLeft;
        }

        public bool IsMovingUpRight() {
            return currentDirection == DiagonalMovingDirection.MovingUpRight;
        }

        public bool IsNotMoving() {
            return currentDirection == DiagonalMovingDirection.IsNotMoving;
        }

        public void InitRandomDirection() {
            int randomDirection = random.Next(0, 4);
            switch (randomDirection) {
                case 0:
                    currentDirection = DiagonalMovingDirection.MovingUpLeft;
                    break;
                case 1:
                    currentDirection = DiagonalMovingDirection.MovingUpRight;
                    break;
                case 2:
                    currentDirection = DiagonalMovingDirection.MovingDownLeft;
                    break;
                case 3:
                    currentDirection = DiagonalMovingDirection.MovingDownRight;
                    break;
            }
        }

        public void InitRandomSafeDirection() {            
            int randomSafeDirection = random.Next(0, 2);
            switch (randomSafeDirection) {
                case 0:
                    currentDirection = DiagonalMovingDirection.MovingUpLeft;
                    break;
                case 1:
                    currentDirection = DiagonalMovingDirection.MovingUpRight;
                    break;
            }
        }

        public void ChangeDirectionToUpLeft() {
            currentDirection = DiagonalMovingDirection.MovingUpLeft;
        }

        public void ChangeDirectionToUpRight() {
            currentDirection = DiagonalMovingDirection.MovingUpRight;
        }

        public void ChangeDirectionToDownLeft() {
            currentDirection = DiagonalMovingDirection.MovingDownLeft;
        }

        public void ChangeDirectionToDownRight() {
            currentDirection = DiagonalMovingDirection.MovingDownRight;
        }


    }
}

Класс, как и следует из его названия, реализует простое диагональное движение. Внутри он имеет поле currentDirection встроенного в него же enum-типа DiagonalMovingDirection. Это поле и хранит текущее направление движения некоторого игрового объекта. Также в классе есть второе поле random, при помощи которого мы генерируем через вызов метода Next псевдослучайные числа в реализации методов InitRandomDirection и InitRandomSafeDirection. Таким образом мы задаём произвольное направление движения для некоторого игрового объекта.

Напомню здесь, что метод Next принимает 2 параметра, задающих диапазон генерируемых чисел: первый параметр задаёт начальное число (включительно) для диапазона, второй - конечное число (исключительно) диапазона, т.е. вызов Next(0, 4), как в нашем примере выше, сгенерирует какое-то произвольное число от 0 до 3, включительно. Число 4 никогда не сгенерируется в этом примере.

Также в классе есть 2 конструктора: один без параметров, второй может принимать одно из допустимых значение enum-типа DiagonalMovingDirection.

Часть 4.3. Пространство имён ArkanoidGameExample.GameObjects

Теперь мы создадим класс для описания какого-то абстрактного игрового объекта. Создаём новый класс с именем GameObject внутри каталога GameObjects. Ниже представлен его полный код на C#, с учётом используемых внешних пространств имён:

using System;
using System.Collections.Generic;
using System.Drawing;
using ArkanoidGameExample.GameObjects.Positioning;

namespace ArkanoidGameExample.GameObjects {
    /// <summary>
    /// Абстрактный игровой объект, который имеет некоторую позицию и название, а также
    /// имеет доступ к объекту игровой статистики для управления данными этой статистики
    /// </summary>
    public abstract class GameObject {
        protected GameObjectPosition position;

        public delegate void MultipleGameObjectsInteractionDelegate(object sender, ICollection<GameObject> otherObjects);
        
        public event MultipleGameObjectsInteractionDelegate CollapsedWithOtherObjects;
        public event EventHandler InitIncrementNumberOfFailures;
        public event EventHandler InitPositiveGameAction;

        protected virtual void OnCollapsedWithOtherObjects(ICollection<GameObject> otherObjects) {
            CollapsedWithOtherObjects?.Invoke(this, otherObjects);
        }
        protected virtual void OnInitIncrementNumberOfFailures() {
            InitIncrementNumberOfFailures?.Invoke(this, new EventArgs());
        }
        protected virtual void OnInitPositiveGameAction() {
            InitPositiveGameAction?.Invoke(this, new EventArgs());
        }

        public string Title { get; set; }

        public GameObjectPosition Position {
            get {
                return position;
            }
        }

        protected GameObject(string title) {
            position = new GameObjectPosition();
            Title = title;
        }

        protected GameObject(string title, int width, int height) {
            position = new GameObjectPosition();
            Title = title;
        }

        public void SetPosition(int x, int y) {
            position.X = x;
            position.Y = y;
        }

        public abstract Rectangle GetObjectRectangle();
    }
}

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

Разберём некоторые части класса:

Теперь мы создадим в каталоге GameObjects универсальный интерфейс IMovingGameObject<T>. Параметр типа T для интерфейса - это тип IMovingDirection или любой другой тип-наследник от IMovingDirection:

using ArkanoidGameExample.GameObjects.MovingDirection;
using ArkanoidGameExample.GameObjects.Positioning;

namespace ArkanoidGameExample.GameObjects {
    /// <summary>
    /// Описывает игровой объект, который может двигаться в любых направлениях, задаваемых интерфейсом IMovingDirection
    /// </summary>
    public interface IMovingGameObject<T> where T : IMovingDirection {
        /// <summary>
        /// Указывает игровому объекту двигаться (изменить своё положение) в текущем направлении движения
        /// </summary>
        void MoveAtCurrentDirection();

        /// <summary>
        /// Может ли игровой объект продолжить движение в текущем направлении движения?
        /// </summary>
        /// <param name="lowerBoundX">левая граница игрового поля, по оси X</param>
        /// <param name="upperBoundX">правая граница игрового поля, по оси X</param>
        /// <param name="lowerBoundY">нижняя (начальная) граница игрового поля, по оси Y. для формы - это верхний край формы</param>
        /// <param name="upperBoundY">верхняя (конечная) граница игрового поля, по оси Y. для формы - это нижний край формы</param>
        /// <param name="upperBoundXDelta">погрешность для границы по оси X, в пикселях</param>
        /// <param name="upperBoundYDelta">погрешность для границы по оси Y, в пикселях</param>
        /// <returns>true, если текущий движущийся игровой объект может продолжать движение в текущем направлении движения, иначе false</returns>
        bool CanMoveAtCurrentDirection(int lowerBoundX, int upperBoundX, int lowerBoundY, int upperBoundY, int upperBoundXDelta, int upperBoundYDelta);

        /// <summary>
        /// Предписывает игровому объекту инициализировать произвольное направление для дальнейшего движения
        /// </summary>
        void InitRandomMovingDirection();

        /// <summary>
        /// Получить текущее направление движения игрового объекта
        /// </summary>
        /// <returns></returns>
        T GetMovingDirection();

        /// <summary>
        /// Установить новое направление движения игрового объекта
        /// </summary>
        /// <param name="movingDirection">новое направление для движения текущего движущегося игрового объекта</param>
        void SetMovingDirection(T movingDirection);

        /// <summary>
        /// Установить граничный объект, или "стену" для игрового объекта, при достижении которой произойдет
        /// некоторое негативное действие в игре (например, проигрыш игрока и завершение игры).
        /// </summary>
        /// <param name="failureWallConstraint">значение граничного объекта-стены</param>
        void SetWallFailureConstraint(WallPosition failureWallConstraint);

        /// <summary>
        /// Достиг ли игровой объект граничного значения (т.е. нижней стены,
        /// которая является признаком поражения для игрока)
        /// </summary>
        /// <returns></returns>
        bool ReachedWallFailureConstraint();

        /// <summary>
        /// Сбросить признак достижения стены, являющейся признаком поражения в игре
        /// </summary>
        void ResetReachedWallFailureConstraint();

        /// <summary>
        /// Установить скорость движения игрового объекта
        /// </summary>
        /// <param name="speed">новая скорость движения</param>
        void SetMovingSpeed(int speed);

        /// <summary>
        /// Получить текущую скорость движения игрового объекта
        /// </summary>
        /// <returns>значение текущей скорости игрового объекта</returns>
        int GetMovingSpeed();
    }
}

Далее создадим в этом же каталоге GameObjects интерфейс IDirectMovingGameObject и отнаследуем его от интерфейса IMovingGameObject, передавая в качестве аргумента его типа интерфейс IHorizontalAndVerticalMovingDirection:

using ArkanoidGameExample.GameObjects.MovingDirection;

namespace ArkanoidGameExample.GameObjects {
    public interface IDirectMovingGameObject : IMovingGameObject<IHorizontalAndVerticalMovingDirection> {
        void MoveRight();
        void MoveLeft();
        void MoveDown();
        void MoveUp();
        void InitRandomDirectMovingDirection();
    }
}

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

Далее создадим в этом же каталоге GameObjects интерфейс IDiagonalMovingGameObject и тоже отнаследуем его от интерфейса IMovingGameObject, но теперь в качестве его типа передадим интерфейс IDiagonalMovingDirection:

using ArkanoidGameExample.GameObjects.MovingDirection;

namespace ArkanoidGameExample.GameObjects {
    public interface IDiagonalMovingGameObject : IMovingGameObject<IDiagonalMovingDirection> {
        void MoveUpRight();
        void MoveUpLeft();
        void MoveDownLeft();
        void MoveDownRight();
        void InitRandomDiagonalMovingDirection();
        void InitRandomSafeDiagonalMovingDirection();
    }
}

Этот интерфейс будет описывать игровые объекты, двигающиеся по диагонали по игровому полю. К таким объектам будет относиться двигающийся по полю шарик. Но шарик умеет не только двигаться по диагонали, но ещё и обладает таким свойством, как отталкиваться от "стен" (границ игрового поля) и от других игровых объектов (от движущейся платформы игрока и от статичных блоков, которые предстоит разбивать игроку).

Поэтому в том же каталоге GameObjects мы создадим для будущего летающего шарика ещё один интерфейс IBouncingDiagonalMovingGameObject и сделаем его наследником от интерфейса IDiagonalMovingGameObject:

using System.Collections.Generic;

namespace ArkanoidGameExample.GameObjects {
    /// <summary>
    /// Описывает "отскакивающий" игровой объект
    /// </summary>
    public interface IBouncingDiagonalMovingGameObject : IDiagonalMovingGameObject {
        /// <summary>
        /// Выполнить действия для осуществления "отскока"
        /// </summary>
        void Bounce();

        /// <summary>
        /// Установить для данного игрового объекта другой объект <paramref name="gameObject"/>, который
        /// будет для него препятствием и вызовет "отскок" текущего объекта
        /// </summary>
        /// <param name="gameObject">другой игровой объект-препятствие, который вызовет эффект "отскока" для текущего объекта</param>
        void SetBounceFromObject(GameObject gameObject);

        /// <summary>
        /// Проверить - есть ли коллизия (пересечение или столкновение) текущего игрового объекта и другого объекта, который
        /// был установлен в качестве препятствия для текущего объекта.
        /// </summary>
        /// <param name="newX">позиция по оси X текущего объекта, для которой нужно выполнить проверку</param>
        /// <param name="newY">позиция по оси Y текущего объекта, для которой нужно выполнить проверку</param>
        /// <returns>true, если произошло столкновение/пересечение текущего объекта с объектом-препятствием</returns>
        bool IsCollisionWithBounceFromObject(int newX, int newY);


        /// <summary>
        /// Установить для данного игрового объекта список других объектов <paramref name="destroyingGameObjects"/>,
        /// которые будут уничтожаться при попадании по ним текущего объекта
        /// </summary>
        /// <param name="destroyingGameObjects"></param>
        void SetBounceFromDestroyingObjects(List<GameObject> destroyingGameObjects);

        /// <summary>
        /// Проверить - есть ли коллизия (пересечение или столкновение) текущего игрового объекта и других уничтожающихся объектов (блоки)
        /// </summary>
        /// <param name="newX">позиция по оси X текущего объекта, для которой нужно выполнить проверку</param>
        /// <param name="newY">позиция по оси Y текущего объекта, для которой нужно выполнить проверку</param>
        /// <returns></returns>
        bool IsCollisionWithDestroyingObjects(int newX, int newY);
    }
}

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

Часть 4.4. Пространство имён ArkanoidGameExample.GameObjects.Instances

Создадим класс, который опишет двигающуюся по игровому полю платформу, управляемую игроком. Этот класс мы назовём RectangularGameObject ("прямоугольный игровой объект"). Этот класс будет являться наследником ранее созданного абстрактного класса GameObject. Ниже представлен полный код для нового класса:

using System.Drawing;

namespace ArkanoidGameExample.GameObjects.Instances {
    /// <summary>
    /// Описывает игровой объект прямоугольной формы (двигающаяся "платформа" игрока)
    /// </summary>
    public class RectangularGameObject : GameObject {
        /// <summary>
        /// Ширина прямоугольного игрового объекта
        /// </summary>
        public int Width { get; set; }

        /// <summary>
        /// Высота прямоугольного игрового объекта
        /// </summary>
        public int Height { get; set; }

        /// <summary>
        /// Цвет игрового объекта
        /// </summary>
        public Color Color { get; set; }

        public RectangularGameObject(string title, int width, int height) : base(title) {
            Width = width;
            Height = height;
        }

        /// <summary>
        /// Получить объект прямоугольника, который описывает габариты/размеры текущего прямоугольного игрового объекта
        /// </summary>
        /// <returns></returns>
        public override Rectangle GetObjectRectangle() {
            return new Rectangle(position.X, position.Y, Width, Height);
        }

        /// <summary>
        /// Получить половину ширины текущего прямоугольного объекта
        /// </summary>
        /// <returns></returns>
        public int GetHalfWidth() {
            return Width / 2;
        }

        public bool CanMoveToPointWhenCentered(Point point, int boundsStartX, int boundsEndX, int rightBoundsDelta) {
            if (point == Point.Empty) {
                return false;
            }
            return point.X - GetHalfWidth() >= boundsStartX && point.X + GetHalfWidth() + rightBoundsDelta < boundsEndX;
        }

        public void SetPositionCenteredHorizontally(int initialX) {
            position.X = initialX - GetHalfWidth();
        }
    }
}

Обратите внимание, что в этом классе мы обязаны реализовать абстрактный метод GetObjectRectangle родительского абстрактного класса GameObject. Код метода - в одну строку, где мы просто создаём и возвращаем экземпляр класса Rectangle, используя текущие координаты X и Y игрового объекта и его ширину (Width) и высоту (Height). Метод GetHalfWidth() - просто возвращает половину ширины прямоугольного объекта. Он нам пригождается в двух других методах класса:

Теперь в этом же каталоге GameObjects/Instances создадим ещё один класс StaticBlock, который будет являться наследником только что созданного класса RectangularGameObject:

using System.Drawing;

namespace ArkanoidGameExample.GameObjects.Instances {
    public class StaticBlock : RectangularGameObject {
        /// <summary>
        /// Цвет границы для статичного блока
        /// </summary>
        public Color BorderColor { get; set; }

        /// <summary>
        /// Цвет заливки ("тела") статичного блока
        /// </summary>
        public Color BodyColor { get; set; }

        /// <summary>
        /// Количество необходимых ударов шариком по статичному блоку, чтобы
        /// его уничтожить
        /// </summary>
        public int HitsToDestroy { get; set; }

        /// <summary>
        /// Счётчик произведённых ударов шариком по статичному блоку
        /// </summary>
        public int CurrentHits { get; set; } = 0;

        /// <summary>
        /// Конструктор. Создаёт экземпляр статичного блока
        /// </summary>
        /// <param name="title">название статичного блока</param>
        /// <param name="width">ширина блока, в пикселях</param>
        /// <param name="height">высота блока, в пикселях</param>
        public StaticBlock(string title, int width, int height) : base(title, width, height) {
        }
    }
}

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

Теперь мы напишем класс BouncingBall ("отскакивающий шарик"), который будет являться одним из ключевых классов для всей нашей игровой логики. Сначала я представлю полный исходный текст этого класса, а после этого мы разберём основные нюансы методов этого класса. Итак, код класса выглядит следующим образом:

using System;
using System.Drawing;
using ArkanoidGameExample.GameObjects.MovingDirection;
using ArkanoidGameExample.GameObjects.Positioning;
using System.Collections.Generic;

namespace ArkanoidGameExample.GameObjects.Instances {
    /// <summary>
    /// Представляет игровой шарик, который умеет отталкиваться от стен (края формы) или
    /// от заданного игрового объекта (движущаяся платформа игрока).
    /// Шарик также может осуществлять диагональные движения в игре - за счёт реализации
    /// интерфейса IBouncingDiagonalMovingGameObject
    /// </summary>
    public class BouncingBall : GameObject, IBouncingDiagonalMovingGameObject {
        /// <summary>
        /// Радиус шарика
        /// </summary>
        protected int radius;

        /// <summary>
        /// Экземпляр класса Random для генерации случайных чисел
        /// </summary>
        protected Random random;

        /// <summary>
        /// Позиция стены, с которой столкнулся шарик
        /// </summary>
        protected WallPosition wallPosition;

        /// <summary>
        /// Позиция той "стены", которая является признаком проигрыша в игре.
        /// В нашем примере это будет нижняя "стена", т.е. нижний край формы
        /// </summary>
        protected WallPosition failureWallConstraint;

        /// <summary>
        /// Достигнуто ли ограничение для того, чтобы игрок проиграл?
        /// (т.е. достигнута ли нижняя "стена", или нижний край формы?)
        /// </summary>
        protected bool reachedFailureConstraint;

        /// <summary>
        /// Скорость движения шарика
        /// </summary>
        protected int movingSpeed;

        /// <summary>
        /// Игровой объект, от которого шарик будет отталкиваться (помимо "стен").
        /// В игре этот объект - экземпляр платформы игрока
        /// </summary>
        protected GameObject movingPlatform;

        /// <summary>
        /// Список уничтожающихся статичных блоков, в которые должен попадать шарик
        /// </summary>
        protected List<GameObject> destroyingStaticBlocks;

        /// <summary>
        /// Признак того, что произошло столкновение с платформой игрока.
        /// </summary>
        protected bool happenedCollisionWithBounceFromObject;

        /// <summary>
        /// Поле описывает диагональное движение в одном из допустимых направлений.
        /// </summary>
        protected IDiagonalMovingDirection diagonalMovingDirection;

        /// <summary>
        /// Радуис шарика
        /// </summary>
        public int Radius {
            get {
                return radius;
            }
        }

        /// <summary>
        /// Конструктор шарика
        /// </summary>
        /// <param name="title">название игрового объекта (шарика)</param>
        /// <param name="radius">радиус шарика</param>
        public BouncingBall(string title, int radius) : base(title) {
            this.radius = radius;
            this.movingSpeed = 1;
            this.reachedFailureConstraint = false;
            this.happenedCollisionWithBounceFromObject = false;
            this.wallPosition = WallPosition.NoWall;
            this.failureWallConstraint = WallPosition.NoWall;
            this.random = new Random();
        }

        /// <summary>
        /// Получить объект класса Rectangle, который описывает местоположение и размеры шарика 
        /// (т.е. фактически это описанный вокруг шарика квадрат)
        /// </summary>
        /// <returns>инициализированный объект класса Rectangle, описывающий текущее местоположение и размеры шарика</returns>
        public override Rectangle GetObjectRectangle() {
            return new Rectangle(position.X, position.Y, 2 * Radius, 2 * Radius);
        }

        /// <summary>
        /// Предписывает шарику двигаться "вверх-вправо" с его текущей скоростью
        /// </summary>
        public void MoveUpRight() {
            position.X += movingSpeed;
            position.Y -= movingSpeed;
        }

        /// <summary>
        /// Предписывает шарику двигаться "вверх-влево" с его текущей скоростью
        /// </summary>
        public void MoveUpLeft() {
            position.X -= movingSpeed;
            position.Y -= movingSpeed;
        }

        /// <summary>
        /// Предписывает шарику двигаться "вниз-влево" с его текущей скоростью
        /// </summary>
        public void MoveDownLeft() {
            position.X -= movingSpeed;
            position.Y += movingSpeed;
        }

        /// <summary>
        /// Предписывает шарику двигаться "вниз-вправо" с его текущей скоростью
        /// </summary>
        public void MoveDownRight() {
            position.X += movingSpeed;
            position.Y += movingSpeed;
        }

        /// <summary>
        /// Инициализировать произвольное направление движения по какой-то из диагоналей и установить его 
        /// в качестве текущего направления движения шарика
        /// </summary>
        public void InitRandomDiagonalMovingDirection() {
            IDiagonalMovingDirection direction = new SimpleDiagonalMovingDirection();
            direction.InitRandomDirection();
            SetMovingDirection(direction);
        }

        /// <summary>
        /// Инициализировать произвольное и безопасное для игрока направление движения по какой-то 
        /// из диагоналей и установить его в качестве текущего направления движения шарика
        /// </summary>
        public void InitRandomSafeDiagonalMovingDirection() {
            IDiagonalMovingDirection safeDirection = new SimpleDiagonalMovingDirection();
            safeDirection.InitRandomSafeDirection();
            SetMovingDirection(safeDirection);
        }

        /// <summary>
        /// Указывает шарику продолжить движение в текущем направлении его движения
        /// </summary>
        public void MoveAtCurrentDirection() {
            if (diagonalMovingDirection == null || diagonalMovingDirection.IsNotMoving()) {
                return;
            }

            if (diagonalMovingDirection.IsMovingUpLeft()) {
                MoveUpLeft();
            } else if (diagonalMovingDirection.IsMovingUpRight()) {
                MoveUpRight();
            } else if (diagonalMovingDirection.IsMovingDownLeft()) {
                MoveDownLeft();
            } else if (diagonalMovingDirection.IsMovingDownRight()) {
                MoveDownRight();
            }
        }

        /// <summary>
        /// Произошло ли столкновение с другим игровым объектом (платформа игрока) в указанной позиции шарика?
        /// </summary>
        /// <param name="newX">координата X шарика, в которой потенциально производится столкновение с платформой игрока</param>
        /// <param name="newY">координата Y шарика, в которой потенциально производится столкновение с платформой игрока</param>
        /// <returns>true, если произошло столкновение, иначе false</returns>
        public bool IsCollisionWithBounceFromObject(int newX, int newY) {

            if (movingPlatform.Position.Y > newY + GetObjectRectangle().Height) {
                // шарик ещё не долетел до низа, поэтому у нас точно нет столкновения с платформой
                return false;
            }

            if (movingPlatform.Position.X > newX + GetObjectRectangle().Width ||
                movingPlatform.Position.X + movingPlatform.GetObjectRectangle().Width < newX) {
                return false;
            }

            happenedCollisionWithBounceFromObject = true;

            // мы отбили шарик, вызываем позитивное действие в игре, которое увеличит нужный счётчик в статистике
            OnInitPositiveGameAction();

            return true;
        }

        /// <summary>
        /// Произошло ли столкновение с одним или несколькими статичными блоками?
        /// </summary>
        /// <param name="newX">координата X шарика, в которой потенциально производится столкновение со статичным блоком</param>
        /// <param name="newY">координата Y шарика, в которой потенциально производится столкновение со статичным блоком</param>
        /// <returns>true, если произошло столкновение, иначе false</returns>
        public bool IsCollisionWithDestroyingObjects(int newX, int newY) {
            if (destroyingStaticBlocks == null || destroyingStaticBlocks.Count == 0) {
                return false;
            }

            List<StaticBlock> destroyedBlocks = new List<StaticBlock>();
            bool isCollisionWithOneOfTheBlocks = false;

            foreach (GameObject blockGameObject in destroyingStaticBlocks) {
                if (blockGameObject.Position.Y + blockGameObject.GetObjectRectangle().Height < newY) {
                    continue;
                }

                if (blockGameObject.Position.X > newX + GetObjectRectangle().Width ||
                    blockGameObject.Position.X + blockGameObject.GetObjectRectangle().Width < newX) {
                    continue;
                }

                if (blockGameObject is StaticBlock staticBlock) {
                    isCollisionWithOneOfTheBlocks = true;
                    if (staticBlock.CurrentHits + 1 >= staticBlock.HitsToDestroy) {
                        destroyedBlocks.Add(staticBlock);
                    } else {
                        staticBlock.CurrentHits++;
                    }
                }
            }

            if (isCollisionWithOneOfTheBlocks) {
                List<GameObject> destroyedBlockObjects = new List<GameObject>(destroyedBlocks);
                OnCollapsedWithOtherObjects(destroyedBlockObjects);
                destroyingStaticBlocks.RemoveAll(block => block is StaticBlock staticBlock && destroyedBlocks.Contains(staticBlock));

                wallPosition = WallPosition.WallFromTheTop;
                return true;
            }

            return false;
        }

        /// <summary>
        /// Может ли шарик двигаться дальше в текущем направлении его движения?
        /// </summary>
        /// <param name="lowerBoundX">левая граница игрового поля, по оси X (левый край формы)</param>
        /// <param name="upperBoundX">правая граница игрового поля, по оси X (правый край формы)</param>
        /// <param name="lowerBoundY">нижняя граница игрового поля, по оси Y (верхний край формы)</param>
        /// <param name="upperBoundY">верхняя граница игрового поля, по оси Y (нижний край формы)</param>
        /// <param name="upperBoundXDelta">допустимая погрешность (дельта) для правой границы игрового поля (для правого края формы)</param>
        /// <param name="upperBoundYDelta">допустимая погрешность (дельта) для верхней границы игрового поля (для нижнего края формы)</param>
        /// <returns>true - если шарик может продолжать движение в текущем направлении, иначе false</returns>
        public bool CanMoveAtCurrentDirection(int lowerBoundX, int upperBoundX, int lowerBoundY, int upperBoundY, int upperBoundXDelta, int upperBoundYDelta) {
            if (diagonalMovingDirection == null || diagonalMovingDirection.IsNotMoving()) {
                return false;
            }

            int newX = 0;
            int newY = 0;

            if (diagonalMovingDirection.IsMovingUpLeft()) {
                newX = position.X - movingSpeed;
                newY = position.Y - movingSpeed;

                if (IsCollisionWithDestroyingObjects(newX, newY)) {
                    return false;
                }

                if (position.X - movingSpeed > lowerBoundX) {
                    if (position.Y - movingSpeed > lowerBoundY) {
                        return true;
                    } else {
                        wallPosition = WallPosition.WallFromTheTop;
                    }
                } else {
                    wallPosition = WallPosition.WallFromTheLeft;
                }
            } else if (diagonalMovingDirection.IsMovingUpRight()) {
                newX = position.X + movingSpeed;
                newY = position.Y - movingSpeed;

                if (IsCollisionWithDestroyingObjects(newX, newY)) {
                    return false;
                }

                if (position.X + movingSpeed + GetObjectRectangle().Width + upperBoundXDelta < upperBoundX) {
                    if (position.Y - movingSpeed > lowerBoundY) {
                        return true;
                    } else {
                        wallPosition = WallPosition.WallFromTheTop;
                    }
                } else {
                    wallPosition = WallPosition.WallFromTheRight;
                }
            } else if (diagonalMovingDirection.IsMovingDownLeft()) {
                newX = position.X - movingSpeed;
                newY = position.Y + movingSpeed + GetObjectRectangle().Height + upperBoundYDelta;

                if (IsCollisionWithBounceFromObject(position.X, position.Y)) {
                    return false;
                }

                if (newX > lowerBoundX) {
                    if (newY < upperBoundY) {
                        return true;
                    } else {
                        wallPosition = WallPosition.WallFromTheBottom;
                        if (wallPosition == failureWallConstraint) {
                            OnInitIncrementNumberOfFailures();
                            reachedFailureConstraint = true;
                        }
                    }
                } else {
                    wallPosition = WallPosition.WallFromTheLeft;
                }
            } else if (diagonalMovingDirection.IsMovingDownRight()) {
                newX = position.X + movingSpeed + GetObjectRectangle().Width + upperBoundXDelta;
                newY = position.Y + movingSpeed + GetObjectRectangle().Height + upperBoundYDelta;

                if (IsCollisionWithBounceFromObject(position.X, position.Y)) {
                    return false;
                }

                if (newX < upperBoundX) {
                    if (newY < upperBoundY) {
                        return true;
                    } else {
                        wallPosition = WallPosition.WallFromTheBottom;
                        if (wallPosition == failureWallConstraint) {
                            OnInitIncrementNumberOfFailures();
                            reachedFailureConstraint = true;
                        }
                    }
                } else {
                    wallPosition = WallPosition.WallFromTheRight;
                }
            }

            return false;
        }

        /// <summary>
        /// Предписывает шарику оттолкнуться от стены при движении "вверх-влево"
        /// </summary>
        private void BounceWhenMovingUpLeft() {
            if (wallPosition == WallPosition.WallFromTheLeft) {
                diagonalMovingDirection.ChangeDirectionToUpRight();
            } else if (wallPosition == WallPosition.WallFromTheTop) {
                diagonalMovingDirection.ChangeDirectionToDownLeft();
            }
        }

        /// <summary>
        /// Предписывает шарику оттолкнуться от стены при движении "вверх-вправо"
        /// </summary>
        private void BounceWhenMovingUpRight() {
            if (wallPosition == WallPosition.WallFromTheRight) {
                diagonalMovingDirection.ChangeDirectionToUpLeft();
            } else if (wallPosition == WallPosition.WallFromTheTop) {
                diagonalMovingDirection.ChangeDirectionToDownRight();
            }
        }

        /// <summary>
        /// Проверить столкновение с платформой игрока, и если оно случилось, то выполнить заданное
        /// действие <paramref name="actionIfCollisionHappened"/>
        /// </summary>
        /// <param name="actionIfCollisionHappened">действие, которое нужно выполнить при столкновении с платформой игрока</param>
        private void CheckForCollisionWithPlatform(Action actionIfCollisionHappened) {
            if (happenedCollisionWithBounceFromObject) {
                actionIfCollisionHappened.Invoke();
                happenedCollisionWithBounceFromObject = false;
            }
        }

        /// <summary>
        /// Проверить столкновение с платформой игрока и оттолкнуться при исходном движении "вниз-влево"
        /// </summary>
        private void CheckForCollisionWithPlatformAndBounceWhenMovingDownLeft() {
            CheckForCollisionWithPlatform(() => diagonalMovingDirection.ChangeDirectionToUpLeft());

            if (wallPosition == WallPosition.WallFromTheLeft) {
                diagonalMovingDirection.ChangeDirectionToDownRight();
            } else if (wallPosition == WallPosition.WallFromTheBottom) {
                diagonalMovingDirection.ChangeDirectionToUpLeft();
            }
        }

        /// <summary>
        /// Проверить столкновение с платформой игрока и оттолкнуться при исходном движении "вниз-вправо"
        /// </summary>
        private void CheckForCollisionWithPlatformAndBounceWhenMovingDownRight() {
            CheckForCollisionWithPlatform(() => diagonalMovingDirection.ChangeDirectionToUpRight());

            if (wallPosition == WallPosition.WallFromTheRight) {
                diagonalMovingDirection.ChangeDirectionToDownLeft();
            } else if (wallPosition == WallPosition.WallFromTheBottom) {
                diagonalMovingDirection.ChangeDirectionToUpRight();
            }
        }

        /// <summary>
        /// Выполняет действие шарика "оттолкнуться" в зависимости от текущего направления
        /// движения шарика
        /// </summary>
        public void Bounce() {
            if (diagonalMovingDirection == null || diagonalMovingDirection.IsNotMoving()) {
                return;
            }

            if (diagonalMovingDirection.IsMovingUpLeft()) {
                BounceWhenMovingUpLeft();
            } else if (diagonalMovingDirection.IsMovingUpRight()) {
                BounceWhenMovingUpRight();
            } else if (diagonalMovingDirection.IsMovingDownLeft()) {
                CheckForCollisionWithPlatformAndBounceWhenMovingDownLeft();
            } else if (diagonalMovingDirection.IsMovingDownRight()) {
                CheckForCollisionWithPlatformAndBounceWhenMovingDownRight();
            }
        }

        /// <summary>
        /// Установить значение "стены", достижение которой является проигрышем в игре.
        /// В игре такой стеной является нижняя "стена".
        /// </summary>
        /// <param name="failureWallConstraint">значение "стены", являющейся условием проигрыша</param>
        public void SetWallFailureConstraint(WallPosition failureWallConstraint) {
            this.failureWallConstraint = failureWallConstraint;
        }

        /// <summary>
        /// Достигнута ли нижняя "стена"? (т.е. нижний край формы)
        /// </summary>
        /// <returns>true - нижняя "стена" достигнута, false - не достигнута</returns>
        public bool ReachedWallFailureConstraint() {
            return reachedFailureConstraint;
        }

        /// <summary>
        /// Изменить скорость движения шарика на новую
        /// </summary>
        /// <param name="speed">новая скорость движения шарика</param>
        public void SetMovingSpeed(int speed) {
            movingSpeed = speed;
        }

        /// <summary>
        /// Получить текущую скорость движения шарика
        /// </summary>
        /// <returns></returns>
        public int GetMovingSpeed() {
            return movingSpeed;
        }

        /// <summary>
        /// Установить другой игровой объект в качестве объекта, от которого
        /// будет отталкиваться шарик. В игре этим объектом выступает движущаяся платформа игрока.
        /// </summary>
        /// <param name="gameObject">другой игровой объект, от которого будет отталкиваться шарик</param>
        public void SetBounceFromObject(GameObject gameObject) {
            movingPlatform = gameObject;
        }

        /// <summary>
        /// Сбросить признак достижения нижней "стены" (которая является
        /// условием проигрыша в игре). Нижняя "стена" - это нижний край формы
        /// </summary>
        public void ResetReachedWallFailureConstraint() {
            reachedFailureConstraint = false;
        }

        /// <summary>
        /// Инициализировать произвольное движение игрового объекта (шарика)
        /// </summary>
        public void InitRandomMovingDirection() {
            InitRandomDiagonalMovingDirection();
        }

        /// <summary>
        /// Получить текущее направление движения шарика
        /// </summary>
        /// <returns></returns>
        public IDiagonalMovingDirection GetMovingDirection() {
            return diagonalMovingDirection;
        }

        /// <summary>
        /// Установить новое направление движения шарика
        /// </summary>
        /// <param name="movingDirection">новое направление движения шарика</param>
        public void SetMovingDirection(IDiagonalMovingDirection movingDirection) {
            this.diagonalMovingDirection = movingDirection;
        }

        /// <summary>
        /// Установить шарику список уничтожаемых статичных блоков для дальнейшей проверки на столкновение
        /// с ними
        /// </summary>
        /// <param name="destroyingGameObjects">список статичных блоков</param>
        public void SetBounceFromDestroyingObjects(List<GameObject> destroyingGameObjects) {
            this.destroyingStaticBlocks = destroyingGameObjects;
        }


    }
}

Разберём детальнее основные методы класса BouncingBall (описание вышло довольно объёмное, если вам проще понять логику по коду самого метода, - пропускайте комментарии для понятных вам методов):

Теперь посмотрим на специфику траектории движения шарика, которая заложена в логику методов BounceWhenMovingUpLeft() и BounceWhenMovingUpRight()

При движении вверх-влево возможны 2 ситуации: когда шарик отскочит от верхней "стены" и полетит вниз-влево (п. 1 на рисунке, зелёная траектория), либо отскочит от левой "стены" и полетит вверх-вправо (п. 1 на рисунке, синяя траектория).

Аналогичным образом, при движении вверх-вправо возможны всего 2 ситуации: когда шарик отскочит от верхней "стены" и полетит вниз-вправо (п. 2 на рисунке, зелёная траектория), либо отскочит от правой "стены" и полетит вверх-влево (п. 2 на рисунке, синяя траектория).

При движении шарика вниз-влево возможна лишь одна траектория отскока от "стен" - это вниз-вправо (п. 3 на рисунке, зелёная траектория), поскольку если шарик долетит до нижней "стены", это автоматически приводит к поражению игрока.

Точно так же если шарик двигается вниз-вправо, возможна лишь одна траектория отскока - это вниз-влево (п. 4 на рисунке, зелёная траектория), поскольку при достижении шариком нижней "стены" это также означает проигрыш игрока.

Реализация для методов CheckForCollisionWithPlatformAndBounceWhenMovingDownLeft() и CheckForCollisionWithPlatformAndBounceWhenMovingDownRight() аналогична описанным выше сценариям, но в них заложены пункты 3 и 4 с единственной допустимой траекторией движения шарика при отскоке от левой/правой стены, а также дополнительно заложена логика отскока от платформы игрока, если игрок вовремя подставил платформу и отбил шарик. В этом случае мы просто меняем траекторию отскока под углом 45° от платформы игрока, представляя платформу в качестве "стены" снизу:

Это следующие развилки else-if в указанных методах:

        private void CheckForCollisionWithPlatformAndBounceWhenMovingDownLeft() {
            // ...
            if ( /* ... */ ) {
                // ...
            } else if (wallPosition == WallPosition.WallFromTheBottom) {
                diagonalMovingDirection.ChangeDirectionToUpLeft();
            }
        }

        private void CheckForCollisionWithPlatformAndBounceWhenMovingDownRight() {
            // ...
            if ( /* ... */ ) {
                // ...
            } else if (wallPosition == WallPosition.WallFromTheBottom) {
                diagonalMovingDirection.ChangeDirectionToUpRight();
            }
        }

Итак, мы написали классы для всех трёх игровых объектов нашей игры.

Теперь мы напишем класс для учёта игровой статистики.

Часть 4.5. Пространство имён ArkanoidGameExample.Statistics

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

Создадим в каталоге Statistics новый класс и назовём его GameStats. Ниже представлен его код:

using System.Collections.Generic;

namespace ArkanoidGameExample.Statistics {
    /// <summary>
    /// Класс описывает игровую статистику и различные возможные её характеристики:
    /// количество побед в игре, количество поражений, а также содержит словарь с 
    /// пользовательскими счётчиками, которые описывают игровой процесс (например: 
    /// количество отбитых шариков и т. д.)
    /// </summary>
    public class GameStats {
        /// <summary>
        /// количество побед/успехов в игре
        /// </summary>
        public int NumberOfWins { get; set; }

        /// <summary>
        /// количество поражений в игре
        /// </summary>
        public int NumberOfFailures { get; set; }

        /// <summary>
        /// Пользовательские игровые счётчики (произвольные).
        /// Позволяют вести различную статистику в игре
        /// </summary>
        private Dictionary<string, int> counters;
        
        /// <summary>
        /// Конструктор
        /// </summary>
        public GameStats() {
            ResetAll();
        }

        /// <summary>
        /// Добавить новый игровой счётчик к объекту игровой статистики
        /// </summary>
        /// <param name="counterKey">название счётчика</param>
        /// <param name="initalValue">начальное значение счётчика</param>
        /// <returns>true, если счётчик был добавлен к объекту игровой статистики, иначе false</returns>
        public bool AddGameCounter(string counterKey, int initalValue) {
            if (counterKey == null) {
                return false;
            }
            if (!counters.ContainsKey(counterKey)) {
                counters.Add(counterKey, initalValue);
                return true;
            }
            return false;
        }

        /// <summary>
        /// Сбросить показания указанного игрового счётчика
        /// </summary>
        /// <param name="counterKey">название счётчика для сброса его значений</param>
        /// <returns>true, если счётчик существует, и его значение было установлено в 0</returns>
        public bool ResetGameCounter(string counterKey) {
            if (counterKey == null) {
                return false;
            }
            if (counters.ContainsKey(counterKey)) {
                counters[counterKey] = 0;
                return true;
            }
            return false;
        }

        /// <summary>
        /// Увеличить на единицу заданный игровой счётчик
        /// </summary>
        /// <param name="counterKey">игровой счётчик, значение которого нужно увеличить на единицу</param>
        /// <returns>true, если счётчик существует, и его значение было увеличено на 1</returns>
        public bool IncrementGameCounter(string counterKey) {
            if (counterKey == null) {
                return false;
            }
            if (counters.ContainsKey(counterKey)) {
                counters[counterKey]++;
                return true;
            }
            return false;
        }

        /// <summary>
        /// Установить заданное значение для игрового счётчика
        /// </summary>
        /// <param name="counterKey">игровой счётчик, значение которого нужно изменить</param>
        /// <param name="value">новое значение для счётчика</param>
        /// <returns>true, если счётчик существует, и его значение было изменено на указанное в параметре <paramref name="value"/></returns>
        public bool SetGameCounterValue(string counterKey, int value) {
            if (counterKey == null) {
                return false;
            }
            if (counters.ContainsKey(counterKey)) {
                counters[counterKey] = value;
                return true;
            }
            return false;
        }

        /// <summary>
        /// Получить значение игрового счётчика
        /// </summary>
        /// <param name="counterKey">игровой счётчик, значение которого нужно получить</param>
        /// <returns>возвращает значение игрового счётчика, если он существует, в противном случае вернёт -1</returns>
        public int GetGameCounterValue(string counterKey) {
            if (counterKey == null) {
                return -1;
            }
            if (counters.ContainsKey(counterKey)) {
                return counters[counterKey];
            }
            return -1;
        }

        /// <summary>
        /// Увеличить количество успешных партий/побед/выигрышей в игре
        /// </summary>
        public void IncrementNumberOfWins() {
            NumberOfWins++;
        }

        /// <summary>
        /// Увеличить количество проигрышных партий/проигрышей/поражений в игре
        /// </summary>
        public void IncrementNumberOfFailures() {
            NumberOfFailures++;
        }

        /// <summary>
        /// Обнулить количество успешных партий/побед/выигрышей в игре
        /// </summary>
        public void ResetNumberOfWins() {
            NumberOfWins = 0;
        }

        /// <summary>
        /// Обнулить количество проигрышных партий/проигрышей/поражений в игре
        /// </summary>
        public void ResetNumberOfFailures() {
            NumberOfFailures = 0;
        }

        /// <summary>
        /// Сбросить все существующие игровые счётчики в 0.
        /// Если словарь счётчиков ещё не был создан, то он будет впервые создан при вызове метода.
        /// </summary>
        public void ResetAllGameCounters() {
            if (counters == null) {
                counters = new Dictionary<string, int>();
                return;
            }

            if (counters.Count > 0) {
                foreach (string counterKey in counters.Keys) {
                    counters[counterKey] = 0;
                }
            }
            
        }

        /// <summary>
        /// Сбросить все показания текущего объекта игровой статистики
        /// </summary>
        public void ResetAll() {
            ResetAllGameCounters();
            ResetNumberOfWins();
            ResetNumberOfFailures();
        }
    }
}

Как видим, в классе в отдельные свойства вынесены ключевые показатели игры - количество побед (свойство NumberOfWins) и количество поражений (свойство NumberOfFailures).

Для хранения любых других статистических показателей (или счётчиков) в классе предусмотрен словарь (или, по-другому, ассоциативный массив) - переменная с типом Dictionary<string, int> и именем counters, а также различные методы для управления счётчиками: добавление нового счётчика, сброс значений счётчика, сброс всех известных счётчиков и так далее. Методы класса документированы в коде, поэтому, думаю, что понятно, для чего они нужны. Словарь counters в качестве ключа хранит определённое имя счётчика в игре и соответствующее ему текущее значение счётчика.

Теперь, когда все основные классы готовы, настал момент, когда мы можем перейти к написанию класса нашего игрового движка.

Часть 4.6. Пространство имён ArkanoidGameExample.Engine

В каталоге Engine создадим класс GameEngine, который будет являться нашим игровым движком и будет содержать основную логику игры. Аналогичным образом, сначала я представлю полный исходный текст класса, а затем мы разберём его устройство и отдельные методы. Итак, полный код класса GameEngine:

using System;
using System.Drawing;
using System.Windows.Forms;
using ArkanoidGameExample.GameObjects.Instances;
using ArkanoidGameExample.GameObjects;
using ArkanoidGameExample.Statistics;
using ArkanoidGameExample.GameObjects.Positioning;
using System.Collections.Generic;

namespace ArkanoidGameExample.Engine {
    public class GameEngine {
        /// <summary>
        /// Ширина движущейся платформы игрока, в пикселях
        /// </summary>
        private const int platformWidth = 120;

        /// <summary>
        /// Высота движущейся платформы игрока, в пикселях
        /// </summary>
        private const int platformHeight = 15;

        /// <summary>
        /// Радиус движущегося шарика, в пикселях
        /// </summary>
        private const int ballRadius = 15;

        /// <summary>
        /// Отступ движущейся платформы игрока от нижней границы игрового поля (т.е. от нижнего края формы)
        /// </summary>
        private const int bottomMargin = 45;

        /// <summary>
        /// Сколько всего мы хотим уровней в игре?
        /// </summary>
        public const int TOTAL_GAME_LEVELS_TO_WIN = 7;

        /// <summary>
        /// Сколько шариков нужно отбить игроку для перехода на новый уровень?
        /// </summary>
        private const int BALLS_TO_PUSH_AWAY_FOR_NEXT_LEVEL = 5;

        /// <summary>
        /// Начальная скорость движения шарика
        /// </summary>
        private const int BALL_STARTING_SPEED = 4;

        private readonly GameObject platform = new RectangularGameObject("Платформа игрока", platformWidth, platformHeight);
        private readonly GameObject ball = new BouncingBall("Шарик", ballRadius);
        private readonly GameStats gameStats = new GameStats();
        private readonly List<StaticBlock> blocks = new List<StaticBlock>();

        public static readonly string GAME_STATS_PUSHED_AWAY_BALLS_TOTAL = "Отбитых шариков за все игры";
        public static readonly string GAME_STATS_PUSHED_AWAY_BALLS_CURRENT = "Отбитых шариков в текущей игре";
        public static readonly string GAME_STATS_CURRENT_BALL_SPEED = "Текущая скорость шарика";
        public static readonly string GAME_STATS_LEVEL = "Текущий уровень";

        private bool isGamePaused = false;

        public bool IsGamePaused {
            get {
                return isGamePaused;
            }
        }            

        /// <summary>
        /// Ширина игрового поля (ширина главной формы)
        /// </summary>
        public int GameFieldWidth { get; set; }

        /// <summary>
        /// Высота игрового поля (высота главной формы)
        /// </summary>
        public int GameFieldHeight { get; set; }

        public GameStats GameStatistics {
            get {
                return gameStats;
            }
        }

        /// <summary>
        /// Показывать ли игровую статистику прямо на игровом поле?
        /// Включить/выключить отображение можно нажатием клавиши S во время игры.
        /// По умолчанию не выводим статистику. Если нужно выводить, то поменять на true.
        /// </summary>
        public bool IsShowStats { get; set; } = true;


        private Timer timer;

        public GameObject PlayerPlatform {
            get {
                return platform;
            }
        }

        public GameObject BouncingBall {
            get {
                return ball;
            }
        }

        public List<StaticBlock> Blocks {
            get {
                return blocks;
            }
        }

        public GameEngine(Timer gameTimer, int gameFieldWidth, int gameFieldHeight) {
            timer = gameTimer;
            GameFieldWidth = gameFieldWidth;
            GameFieldHeight = gameFieldHeight;
        }

        public void StartGame() {
            InitGameObjectsPositionsAndState();
            timer.Start();
        }


        private void GenerateBlocksForCurrentLevel() {
            int blockWidth = 60;
            int blockHeight = 15;

            blocks.Clear();

            int currentGameLevel = gameStats.GetGameCounterValue(GAME_STATS_LEVEL);

            switch (currentGameLevel) {
                case 1:
                    // для 1-го уровня игры генерируем 3 одинаковых ряда сиреневых блоков, для разрушения каждого из блоков достаточно одного удара.
                    for (int blockLayer = 1; blockLayer <= 2; blockLayer++) {
                        for (int n = 1; n <= 7; n++) {
                            StaticBlock block = new StaticBlock("Блок #" + n + ", ряд #" + blockLayer, blockWidth, blockHeight);
                            block.Position.X = (n - 1) * blockWidth;
                            block.Position.Y = (blockLayer - 1) * blockHeight;
                            block.BorderColor = Color.Orange;
                            block.BodyColor = Color.Purple;
                            block.HitsToDestroy = 1;
                            blocks.Add(block);
                        }
                    }
                    break;
                case 2:
                    // для 2го уровня игры генерируем 3 ряда блоков:
                    // 1-й ряд - синие блоки (нужно 2 удара для уничтожения блока)
                    // 2-й и 3-й ряд - сиреневые блоки (нужен 1 удар для уничтожения блока)                    
                    for (int blockLayer = 1; blockLayer <= 3; blockLayer++) {
                        for (int n = 1; n <= 7; n++) {
                            StaticBlock block = new StaticBlock("Блок #" + n + ", ряд #" + blockLayer, blockWidth, blockHeight);
                            block.Position.X = (n - 1) * blockWidth;
                            block.Position.Y = (blockLayer - 1) * blockHeight;
                            block.BorderColor = Color.Orange;
                            if (blockLayer == 1) {
                                block.BodyColor = Color.Blue;
                                block.HitsToDestroy = 2;
                            } else {
                                block.BodyColor = Color.Purple;
                                block.HitsToDestroy = 1;
                            }
                            blocks.Add(block);
                        }
                    }
                    break;
                case 3:
                    // для 3го уровня игры генерируем 4 ряда блоков:
                    // 1-й и 2-й ряд - синие блоки (нужно по 2 удара для уничтожения каждого блока)
                    // 3-й и 4-й ряд - сиреневые блоки (нужен 1 удар для уничтожения каждого блока)
                    for (int blockLayer = 1; blockLayer <= 4; blockLayer++) {
                        for (int n = 1; n <= 7; n++) {
                            StaticBlock block = new StaticBlock("Блок #" + n + ", ряд #" + blockLayer, blockWidth, blockHeight);
                            block.Position.X = (n - 1) * blockWidth;
                            block.Position.Y = (blockLayer - 1) * blockHeight;
                            block.BorderColor = Color.Orange;
                            if (blockLayer == 1 || blockLayer == 2) {
                                block.BodyColor = Color.Blue;
                                block.HitsToDestroy = 2;
                            } else {
                                block.BodyColor = Color.Purple;
                                block.HitsToDestroy = 1;
                            }
                            blocks.Add(block);
                        }
                    }
                    break;
                case 4:
                    // для 4го уровня генерируем 4 ряда блоков:
                    // 1-й ряд - коричневые блоки (нужно по 3 удара для уничтожения каждого блока)
                    // 2-й ряд - синие блоки (нужно по 2 удара для уничтожения каждого блока)
                    // 3-й и 4-й ряд - сиреневые блоки (нужен 1 удар для уничтожения каждого блока)
                    for (int blockLayer = 1; blockLayer <= 4; blockLayer++) {
                        for (int n = 1; n <= 7; n++) {
                            StaticBlock block = new StaticBlock("Блок #" + n + ", ряд #" + blockLayer, blockWidth, blockHeight);
                            block.Position.X = (n - 1) * blockWidth;
                            block.Position.Y = (blockLayer - 1) * blockHeight;
                            block.BorderColor = Color.Orange;
                            if (blockLayer == 1) {
                                block.BodyColor = Color.Brown;
                                block.HitsToDestroy = 3;
                            } else if (blockLayer == 2) {
                                block.BodyColor = Color.Blue;
                                block.HitsToDestroy = 2;
                            } else {
                                block.BodyColor = Color.Purple;
                                block.HitsToDestroy = 1;
                            }
                            blocks.Add(block);
                        }
                    }
                    break;
                case 5:
                    // для 5го уровня генерируем 4 ряда блоков:
                    // 1-й ряд - коричневые блоки (нужно по 3 удара для уничтожения каждого блока)
                    // 2-й, 3-й - синие блоки (нужно по 2 удара для уничтожения каждого блока)                    
                    // 4-й ряд - сиреневые блоки (нужен 1 удар для уничтожения каждого блока)
                    for (int blockLayer = 1; blockLayer <= 4; blockLayer++) {
                        for (int n = 1; n <= 7; n++) {
                            StaticBlock block = new StaticBlock("Блок #" + n + ", ряд #" + blockLayer, blockWidth, blockHeight);
                            block.Position.X = (n - 1) * blockWidth;
                            block.Position.Y = (blockLayer - 1) * blockHeight;
                            block.BorderColor = Color.Orange;
                            if (blockLayer == 1) {
                                block.BodyColor = Color.Brown;
                                block.HitsToDestroy = 3;
                            } else if (blockLayer == 2 || blockLayer == 3) { 
                                block.BodyColor = Color.Blue;
                                block.HitsToDestroy = 2;
                            } else {
                                block.BodyColor = Color.Purple;
                                block.HitsToDestroy = 1;
                            }
                            blocks.Add(block);
                        }
                    }
                    break;
                case 6:
                    // для 6го уровня генерируем 5 рядов блоков:
                    // 1-й и 2-й ряд - коричневые блоки (нужно по 3 удара для уничтожения каждого блока)
                    // 3-й, 4-й - синие блоки (нужно по 2 удара для уничтожения каждого блока)
                    // 5-й ряд - сиреневые блоки (нужно 1 удар для уничтожения каждого блока)
                    for (int blockLayer = 1; blockLayer <= 5; blockLayer++) {
                        for (int n = 1; n <= 7; n++) {
                            StaticBlock block = new StaticBlock("Блок #" + n + ", ряд #" + blockLayer, blockWidth, blockHeight);
                            block.Position.X = (n - 1) * blockWidth;
                            block.Position.Y = (blockLayer - 1) * blockHeight;
                            block.BorderColor = Color.Orange;
                            if (blockLayer == 1 || blockLayer == 2) {
                                block.BodyColor = Color.Brown;
                                block.HitsToDestroy = 3;
                            } else if (blockLayer == 3 || blockLayer == 4) {
                                block.BodyColor = Color.Blue;
                                block.HitsToDestroy = 2;
                            } else {
                                block.BodyColor = Color.Purple;
                                block.HitsToDestroy = 1;
                            }
                            blocks.Add(block);
                        }
                    }
                    break;
            }
        }

        /// <summary>
        /// Инициализировать позиции и состояния игровых объектов
        /// </summary>
        public void InitGameObjectsPositionsAndState() {
            ResetObjectsPositions();

            gameStats.AddGameCounter(GAME_STATS_PUSHED_AWAY_BALLS_TOTAL, 0);
            gameStats.AddGameCounter(GAME_STATS_PUSHED_AWAY_BALLS_CURRENT, 0);
            gameStats.AddGameCounter(GAME_STATS_LEVEL, 1);
            gameStats.AddGameCounter(GAME_STATS_CURRENT_BALL_SPEED, BALL_STARTING_SPEED);

            ball.CollapsedWithOtherObjects += Ball_CollapsedWithOtherObjects;
            ball.InitIncrementNumberOfFailures += Ball_InitIncrementNumberOfFailures;
            ball.InitPositiveGameAction += Ball_InitPositiveGameAction;

            IBouncingDiagonalMovingGameObject bouncingBall = ball as IBouncingDiagonalMovingGameObject;

            // указываем шарику, что объект, от которого он будет отталкиваться - это платформа игрока
            bouncingBall.SetBounceFromObject(platform);

            GenerateBlocksAndSetThemAsDestroyingObjects(bouncingBall);

            InitializeBallMovement(bouncingBall);

            // достижение стены снизу - признак окончания игры
            bouncingBall.SetWallFailureConstraint(WallPosition.WallFromTheBottom);
        }

        private void Ball_InitPositiveGameAction(object sender, EventArgs e) {
            gameStats.IncrementGameCounter(GAME_STATS_PUSHED_AWAY_BALLS_TOTAL);
            gameStats.IncrementGameCounter(GAME_STATS_PUSHED_AWAY_BALLS_CURRENT);
        }

        private void Ball_InitIncrementNumberOfFailures(object sender, EventArgs e) {
            gameStats.IncrementNumberOfFailures();
        }

        private void Ball_CollapsedWithOtherObjects(object sender, ICollection<GameObject> destroyedBlocks) {
            Blocks.RemoveAll(block => destroyedBlocks.Contains(block));
        }

        private void GenerateBlocksAndSetThemAsDestroyingObjects(IBouncingDiagonalMovingGameObject bouncingBall) {
            GenerateBlocksForCurrentLevel();
            List<GameObject> destroyingBlocks = new List<GameObject>(blocks);
            bouncingBall.SetBounceFromDestroyingObjects(destroyingBlocks);
        }


        /// <summary>
        /// Сбросить положения игровых объектов в начальные
        /// </summary>
        public void ResetObjectsPositions() {
            platform.SetPosition(GameFieldWidth / 2 - platformWidth / 2, GameFieldHeight - platformHeight - bottomMargin);
            ball.SetPosition(GameFieldWidth / 2 - ballRadius, GameFieldHeight / 2 - ballRadius);
        }

        /// <summary>
        /// Инициализировать движение игрового шарика
        /// </summary>
        /// <param name="bouncingBall">объект игрового шарика</param>
        public void InitializeBallMovement(IBouncingDiagonalMovingGameObject bouncingBall) {
            // инициируем произвольное, но "безопасное для игрока" движение шарика в диагональном направлении.
            // безопасное - когда шарик полетит либо вверх-вправо, либо вверх-влево, давая игроку время на то, 
            // чтобы включиться в игру и отреагировать
            bouncingBall.InitRandomSafeDiagonalMovingDirection();

            // Если нужно позволить шарику двигаться на старте игры в любом направлении -
            // закомментировать предыдущую строку кода и раскомментировать ту, что ниже:
            //bouncingBall.InitRandomDiagonalMovingDirection();

            // задаём начальную скорость движения шарика
            bouncingBall.SetMovingSpeed(BALL_STARTING_SPEED);
        }

        /// <summary>
        /// Сброс игры и показателей, относящихся к выигранной/проигранной текущей игре.
        /// </summary>
        public void ResetGame() {
            gameStats.ResetGameCounter(GAME_STATS_PUSHED_AWAY_BALLS_CURRENT);
            gameStats.SetGameCounterValue(GAME_STATS_LEVEL, 1);
            gameStats.SetGameCounterValue(GAME_STATS_CURRENT_BALL_SPEED, BALL_STARTING_SPEED);
            ResetObjectsPositions();
            GenerateBlocksAndSetThemAsDestroyingObjects(ball as IBouncingDiagonalMovingGameObject);
            InitializeBallMovement(ball as IBouncingDiagonalMovingGameObject);


            // возвращаем размер платформы игрока в исходный
            (platform as RectangularGameObject).Width = platformWidth;
        }

        public void HandleMouseMove(Point mouseCursorLocation) {
            RectangularGameObject rectangularPlatform = platform as RectangularGameObject;
            if (rectangularPlatform.CanMoveToPointWhenCentered(mouseCursorLocation, 0, GameFieldWidth, 15)) {
                rectangularPlatform.SetPositionCenteredHorizontally(mouseCursorLocation.X);
            }
        }

        public void CheckForGameLevelIncrease() {
            if (blocks.Count > 0) {
                // ещё остались блоки, не повышать уровень игры
                return;
            }

            int currentPushedAwayBalls = gameStats.GetGameCounterValue(GAME_STATS_PUSHED_AWAY_BALLS_CURRENT);
            int currentGameLevel = gameStats.GetGameCounterValue(GAME_STATS_LEVEL);

            IBouncingDiagonalMovingGameObject bouncingBall = ball as IBouncingDiagonalMovingGameObject;
            RectangularGameObject rectPlayerPlatform = platform as RectangularGameObject;

            int currentBallSpeed = bouncingBall.GetMovingSpeed();

            for (int level = 1; level <= TOTAL_GAME_LEVELS_TO_WIN; level++) {
                if (currentGameLevel == level /*&& currentPushedAwayBalls >= BALLS_TO_PUSH_AWAY_FOR_NEXT_LEVEL * currentGameLevel*/) {                    
                    gameStats.IncrementGameCounter(GAME_STATS_LEVEL);

                    GenerateBlocksAndSetThemAsDestroyingObjects(bouncingBall);
                    
                    int newBallSpeed = currentBallSpeed + 1;
                    // увеличиваем скорость движения шарика при достижении очередного уровня
                    bouncingBall.SetMovingSpeed(newBallSpeed);
                    gameStats.SetGameCounterValue(GAME_STATS_CURRENT_BALL_SPEED, newBallSpeed);

                    // уменьшаем ширину движущейся платформы игрока на 10 пикселей (можно закомментировать, если это не нужно):
                    rectPlayerPlatform.Width -= 10;                                       
                    break;
                }
            }
        }

        public void PauseGame(Action actionWhenPaused) {
            timer.Stop();
            isGamePaused = true;
            actionWhenPaused.Invoke();
        }

        public void UnpauseGame() {
            timer.Start();
            isGamePaused = false;
        }

        public void ToggleGamePauseMode(Action actionWhenPaused) {
            if (isGamePaused) {
                UnpauseGame();
            } else {
                PauseGame(actionWhenPaused);
            }
        }

        public void RestartGame() {
            if (!timer.Enabled) {
                IBouncingDiagonalMovingGameObject bouncingMovingBall = ball as IBouncingDiagonalMovingGameObject;
                bouncingMovingBall.ResetReachedWallFailureConstraint();
                ResetGame();
                timer.Start();
            }
        }

        public void HandleGameCycle(Action actionIfWinHappened, Func<bool> funcIsContinueWhenGameIsOver, Func<int, bool> funcIsRepeatAfterWin) {
            IBouncingDiagonalMovingGameObject bouncingMovingBall = ball as IBouncingDiagonalMovingGameObject;

            if (bouncingMovingBall.CanMoveAtCurrentDirection(0, GameFieldWidth, 0, GameFieldHeight, 15, 37)) {
                bouncingMovingBall.MoveAtCurrentDirection();
            } else {
                if (bouncingMovingBall.ReachedWallFailureConstraint()) {
                    timer.Stop();                    
                    if (funcIsContinueWhenGameIsOver.Invoke()) {
                        // перезапускаем игру
                        bouncingMovingBall.ResetReachedWallFailureConstraint();
                        ResetGame();
                        timer.Start();
                    }
                } else {
                    bouncingMovingBall.Bounce();
                    CheckForGameLevelIncrease();
                    CheckForWin(() => {
                        timer.Stop();
                        actionIfWinHappened.Invoke();
                    }, 
                    funcIsRepeatAfterWin);
                }
            }
        }


        public void CheckForWin(Action actionIfWinHappened, Func<int, bool> funcIsRepeatAfterWin) {
            int currentGameLevel = gameStats.GetGameCounterValue(GAME_STATS_LEVEL);
            if (currentGameLevel == TOTAL_GAME_LEVELS_TO_WIN) {
                gameStats.IncrementNumberOfWins();
                actionIfWinHappened.Invoke();
                if (funcIsRepeatAfterWin.Invoke(currentGameLevel)) {
                    // выбрали начать игру заново
                    ResetGame();
                    timer.Start();
                }
            }
        }
    }
}

Разберём основные методы нашего игрового движка (если код движка вам понятен, то описание методов можете пропустить или прочитать лишь про те, которые вам интересны):

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

Часть 4.7. Пространство имён ArkanoidGameExample.Graphics

В каталоге Graphics мы создадим класс и назовём его GameObjectsRenderer. Ниже представлен его код:

using System.Drawing;
using ArkanoidGameExample.Engine;
using ArkanoidGameExample.Statistics;
using ArkanoidGameExample.GameObjects;
using System.Drawing.Drawing2D;
using ArkanoidGameExample.GameObjects.Instances;

namespace ArkanoidGameExample.GameGraphics {
    /// <summary>
    /// Отвечает за отрисовку всех игровых объектов в игре
    /// </summary>
    public class GameObjectsRenderer {
        private readonly GameEngine gameEngine;

        public GameObjectsRenderer(GameEngine gameEngine) {
            this.gameEngine = gameEngine;
        }

        /// <summary>
        /// Отрисовать игровую статистику
        /// </summary>
        /// <param name="g"></param>
        private void RenderGameStats(Graphics g) {
            if (gameEngine.IsShowStats) {
                GameStats gameStats = gameEngine.GameStatistics;
                Font statsFont = new Font("Ubuntu Mono", 12);
                string pushedAwayBallsStats = string.Format("{0}: {1}\r\n{2}: {3}\r\n{4}: {5} / {6}\r\n{7}: {8}\r\n{9}: {10}\r\n{11}: {12}\r\n\r\n[S] или средняя кнопка мыши\r\n - скрыть/показать статистику",
                    GameEngine.GAME_STATS_PUSHED_AWAY_BALLS_TOTAL, // {0}
                    gameStats.GetGameCounterValue(GameEngine.GAME_STATS_PUSHED_AWAY_BALLS_TOTAL), // {1}
                    GameEngine.GAME_STATS_PUSHED_AWAY_BALLS_CURRENT, // {2}
                    gameStats.GetGameCounterValue(GameEngine.GAME_STATS_PUSHED_AWAY_BALLS_CURRENT), // {3}
                    GameEngine.GAME_STATS_LEVEL, // {4}
                    gameStats.GetGameCounterValue(GameEngine.GAME_STATS_LEVEL), // {5}
                    GameEngine.TOTAL_GAME_LEVELS_TO_WIN, // {6}
                    GameEngine.GAME_STATS_CURRENT_BALL_SPEED, // {7}
                    gameStats.GetGameCounterValue(GameEngine.GAME_STATS_CURRENT_BALL_SPEED), // {8}
                    "Проиграно игр", // {9}
                    gameStats.NumberOfFailures, // {10}
                    "Выиграно игр", // {11}
                    gameStats.NumberOfWins); // {12}

                g.DrawString(pushedAwayBallsStats, statsFont, Brushes.Lime, new PointF(0, 150));

                statsFont.Dispose();
            }
        }

        /// <summary>
        /// Отрисовать платформу игрока
        /// </summary>
        /// <param name="g"></param>
        private void RenderPlayerPlatform(Graphics g) {
            GameObject platform = gameEngine.PlayerPlatform;
            Rectangle platformRect = platform.GetObjectRectangle();
            g.FillRectangle(Brushes.Crimson, platformRect);
        }

        /// <summary>
        /// Отрисовать двигающийся шарик
        /// </summary>
        /// <param name="g"></param>
        private void RenderBouncingBall(Graphics g) {
            GameObject ball = gameEngine.BouncingBall;
            Rectangle ballRect = ball.GetObjectRectangle();
            Rectangle ballOuterRect = new Rectangle(ballRect.X + 4, ballRect.Y + 4, ballRect.Width - 8, ballRect.Height - 8);
            g.FillEllipse(Brushes.Goldenrod, ballRect);
            g.FillEllipse(Brushes.DarkGoldenrod, ballOuterRect);
        }

        /// <summary>
        /// Отрисовать статичные блоки
        /// </summary>
        /// <param name="g"></param>
        private void RenderStaticBlocks(Graphics g) {
            foreach (StaticBlock block in gameEngine.Blocks) {
                Rectangle blockBorderRect = block.GetObjectRectangle();
                Pen blockBorderPen = new Pen(block.BorderColor, 2);
                Brush blockBodyBrush = new SolidBrush(block.BodyColor);

                g.DrawRectangle(blockBorderPen, blockBorderRect);

                Rectangle blockBodyRect = new Rectangle(blockBorderRect.X + 1, blockBorderRect.Y + 1, blockBorderRect.Width - 1, blockBorderRect.Height - 1);
                g.FillRectangle(blockBodyBrush, blockBodyRect);

                Font fontHits = new Font("Arial", 8, FontStyle.Bold);
                g.DrawString(block.CurrentHits + " / " + block.HitsToDestroy, fontHits, Brushes.White, new PointF(blockBodyRect.X + 2, blockBodyRect.Y));

                blockBodyBrush.Dispose();
                blockBorderPen.Dispose();
                fontHits.Dispose();
            }
        }

        /// <summary>
        /// Отрисовать сообщение о том, что игра на паузе, если в игровом движке выставлен признак паузы
        /// </summary>
        /// <param name="g"></param>
        private void RenderGamePausedMessage(Graphics g) {
            if (gameEngine.IsGamePaused) {
                Font pausedFont = new Font("Arial", 16);
                string pausedMessage = "     Игра на паузе...\r\n[Space] - Продолжить\r\n[Esc] - Выход из игры";
                SizeF pausedMessageSize = g.MeasureString(pausedMessage, pausedFont);

                g.DrawString(pausedMessage, pausedFont, Brushes.Yellow,
                    new PointF(
                        gameEngine.GameFieldWidth / 2 - pausedMessageSize.Width / 2,
                        gameEngine.GameFieldHeight / 2 - pausedMessageSize.Height / 2 + 100
                    )
                );

                pausedFont.Dispose();
            }
        }

        /// <summary>
        /// Главный открытый метод класса по отрисовке всех игровых объектов на игровом поле
        /// </summary>
        /// <param name="g"></param>
        public void RenderGameObjects(Graphics g) {
            //g.SmoothingMode = SmoothingMode.AntiAlias;
            g.SmoothingMode = SmoothingMode.HighQuality;

            RenderGameStats(g);
            RenderPlayerPlatform(g);
            RenderBouncingBall(g);
            RenderStaticBlocks(g);
            RenderGamePausedMessage(g);
        }
    }
}

Методы класса снабжены комментариями, поэтому я опишу некоторые важные, на мой взгляд, нюансы:

Для центрирования текста (сообщение о том, что игра поставлена на паузу), используется метод MeasureString для объекта класса Graphics:

SizeF pausedMessageSize = g.MeasureString(pausedMessage, pausedFont);

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

Для отрисовки летающего по полю шарика мы используем заполнение двух эллипсов (метод FillEllipse), которые в нашем случае являются не эллипсами, а кругами. Один круг (главный) описывается прямоугольником ballRect - это непосредственно наш шарик. Для красоты внутри него рисуем ещё один, вложенный, чуть уменьшая размеры описанного вокруг него прямоугольника ballOuterRect:

        /// <summary>
        /// Отрисовать двигающийся шарик
        /// </summary>
        /// <param name="g"></param>
        private void RenderBouncingBall(Graphics g) {
            GameObject ball = gameEngine.BouncingBall;
            Rectangle ballRect = ball.GetObjectRectangle();
            Rectangle ballOuterRect = new Rectangle(ballRect.X + 4, ballRect.Y + 4, ballRect.Width - 8, ballRect.Height - 8);
            g.FillEllipse(Brushes.Goldenrod, ballRect);
            g.FillEllipse(Brushes.DarkGoldenrod, ballOuterRect);
        }

Платформа игрока рисуется вызовом метода FillRectangle для объекта графики g. Цвет для платформы используем малиновый (Brushes.Crimson). Можете поэкспериментировать и поменять его на тот, который вам больше нравится:

        /// <summary>
        /// Отрисовать платформу игрока
        /// </summary>
        /// <param name="g"></param>
        private void RenderPlayerPlatform(Graphics g) {
            GameObject platform = gameEngine.PlayerPlatform;
            Rectangle platformRect = platform.GetObjectRectangle();
            g.FillRectangle(Brushes.Crimson, platformRect);
        }

В методе RenderGameStats мы используем отрисовку всей игровой статистики в точке с координатами (0; 150), т.е. 0 по оси X, значит текст прижимается к левой границе игрового поля, а 150 - это 150 пикселей от верхней границы поля. Можете также поменять это местоположение, если необходимо.

Важный момент: все методы класса GameObjectsRenderer сделаны закрытыми (private), за исключением главного открытого метода по отрисовке всех игровых объектов - RenderGameObjects. Это не случайно: мы хотим закрыть всю внутреннюю логику этого класса для всех остальных классов, включая даже код главной формы. Только этот класс ответственен за то, как именно нарисовать каждый игровой объект нашей игры. Ни игровой движок, ни формы не должны "влезать" в его ответственность. Сам же класс, как видно по его коду, принимает в конструкторе объект игрового движка. Это нужно, чтобы получить важные параметры, влияющие на отрисовку, например, понять, поставлена ли игра на паузу или нет. Или понять - надо ли выводить игровую статистику или нет.

Часть 5. Программируем поведение главной формы.

Для главной формы нам потребуется обработать следующие события:

Создаём пустые методы-обработчики для всех указанных событий, перейдя в окне "Свойства"  в область событий для главной формы (иконка с изображением "молнии") и дважды прокликаем напротив имён указанных выше в списке событий, чтобы для них сгенерировались обработчики в коде главной формы.

Также нам нужно поместить на форму из "Панели элементов" единственный элемент управления Timer и установить для него следующие свойства:

Дважды кликнем мышью на этом элементе таймера, чтобы в коде главной формы также сгенерировался обработчик для события Tick таймера.

Теперь мы можем написать код для главной формы FrmArkanoidMain. Полный код главной формы выглядит следующим образом:

using System;
using System.Windows.Forms;
using ArkanoidGameExample.Engine;
using ArkanoidGameExample.GameGraphics;

namespace ArkanoidGameExample {
    public partial class FrmArkanoidMain : Form {
        private GameEngine gameEngine;
        private GameObjectsRenderer gameObjectsRenderer;

        public FrmArkanoidMain() {
            InitializeComponent();
        }

        private void FrmArkanoidMain_Load(object sender, EventArgs e) {
            DoubleBuffered = true;
            gameEngine = new GameEngine(GameIterationTimer, Width, Height);
            gameObjectsRenderer = new GameObjectsRenderer(gameEngine);
            gameEngine.StartGame();
            Invalidate();
        }

        private void FrmArkanoidMain_Paint(object sender, PaintEventArgs e) {
            gameObjectsRenderer.RenderGameObjects(e.Graphics);
        }

        private void FrmArkanoidMain_MouseMove(object sender, MouseEventArgs e) {
            gameEngine.HandleMouseMove(e.Location);
        }

        private void FrmArkanoidMain_KeyDown(object sender, KeyEventArgs e) {
            if (e.KeyCode == Keys.Space) {
                // пробел - поставить/снять игру на паузу. когда игра ставится на паузу, 
                // нужно выполнить дополнительное действие - вызвать метод Invalidate() формы для
                // принудительной перерисовки всех игровых объектов, поскольку таймер в момент паузы останавливается
                // и gameObjectsRenderer не успеет отрисовать сам текст о том, что игра стоит на паузе
                gameEngine.ToggleGamePauseMode(() => Invalidate());
            } else if (e.KeyCode == Keys.Escape) {
                // клавиша Escape - это выход из игры. прежде чем выйти из игры, мы поставим игру на паузу:
                gameEngine.PauseGame(() => Invalidate());

                // а затем покажем диалоговое окно пользователю. Если он выберет выход из игры - выходим, иначе снимаем игру с паузы и продолжаем
                DialogResult dlgResult = MessageBox.Show("Точно выйти из игры?", "Выход", MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question);
                if (dlgResult == DialogResult.Yes) {
                    Application.Exit();
                } else {
                    gameEngine.UnpauseGame();
                }
            } else if (e.KeyCode == Keys.Enter) {
                // нажатие на Enter - перезапуск игры
                gameEngine.RestartGame();
            } else if (e.KeyCode == Keys.S) {
                gameEngine.IsShowStats = !gameEngine.IsShowStats;
            }
        }

        private void FrmArkanoidMain_MouseClick(object sender, MouseEventArgs e) {
            if (e.Button == MouseButtons.Right) {
                // нажатие на правую кнопку также включает/выключает режим паузы в игре, как
                // и клавиша пробела
                gameEngine.ToggleGamePauseMode(() => Invalidate());
            } else if (e.Button == MouseButtons.Middle) {
                gameEngine.IsShowStats = !gameEngine.IsShowStats;
            }
        }

        private void GameIterationTimer_Tick(object sender, EventArgs e) {
            Func<bool> funcIsNeedToContinueWhenGameIsOver = () => {
                DialogResult dlgResult = MessageBox.Show(
                    "Вы проиграли... Начать заново?\r\nВы можете отказаться сейчас и начать игру позже, нажав Enter",
                    "Проигрыш :(",
                    MessageBoxButtons.YesNo,
                    MessageBoxIcon.Warning
                );

                if (dlgResult == DialogResult.Yes) {
                    return true;
                }
                return false;
            };

            Func<int, bool> funcIsNeedToRepeatAfterWin = (currentGameLevel) => {
                DialogResult dlgResult = MessageBox.Show(
                    "Поздравляем, вы победили, дойдя до последнего уровня " + currentGameLevel + "!\r\nСыграем ещё раз?\r\n",
                    "Победа!",
                    MessageBoxButtons.YesNo,
                    MessageBoxIcon.Question
                );

                if (dlgResult == DialogResult.Yes) {
                    return true;
                }
                return false;
            };

            gameEngine.HandleGameCycle(
                () => Invalidate(),
                funcIsNeedToContinueWhenGameIsOver,
                funcIsNeedToRepeatAfterWin
            );

            Invalidate();
        }
    }
}

Разберём код главной формы. В методе загрузки мы устанавливаем свойство DoubleBuffered для формы в значение true, поскольку мы хотим исключить не очень приятные и ненужные эффекты "мерцания" всех наших игровых объектов. Далее мы создаём объект игрового движка, передавая ему экземпляр таймера, отсчитывающего игровые циклы, а также размеры формы. Мы также создаём объект класса GameObjectsRenderer, передавая ему только что созданный игровой движок. Наконец, мы вызываем метод StartGame() игрового движка для запуска игры и сразу же вызываем метод Invalidate() для того, чтобы сделать невалидной видимую часть формы и вызвать её перерисовку (что, в свою очередь, вызывает событие Paint для формы, где будут нарисованы все наши игровые объекты):

        private void FrmArkanoidMain_Load(object sender, EventArgs e) {
            DoubleBuffered = true;
            gameEngine = new GameEngine(GameIterationTimer, Width, Height);
            gameObjectsRenderer = new GameObjectsRenderer(gameEngine);
            gameEngine.StartGame();
            Invalidate();
        }

Обработчики событий Paint и MouseMove формы просто вызывают соответствующие методы для отрисовщика игровых объектов и игрового движка:

        private void FrmArkanoidMain_Paint(object sender, PaintEventArgs e) {
            gameObjectsRenderer.RenderGameObjects(e.Graphics);
        }

        private void FrmArkanoidMain_MouseMove(object sender, MouseEventArgs e) {
            gameEngine.HandleMouseMove(e.Location);
        }

В методах-обработчиках FrmArkanoidMain_KeyDown и FrmArkanoidMain_MouseClick вызываются различные методы игрового движка, в зависимости от нажатой игроком клавиши или кнопки мыши. В некоторые из этих методов передаётся лямбда-выражение, например, при нажатии клавиши пробела:

gameEngine.ToggleGamePauseMode(() => Invalidate());

Это лямбда-выражение задаёт дополнительное действие, которое необходимо выполнить на главной форме. В нашем случае - вызов метода Invalidate() для перерисовки главной формы (и снова вызова события Paint).

В методе GameIterationTimer_Tick написан код, который описывает "один игровой цикл" и будет выполняться со скоростью 1 раз в 10 миллисекунд, поскольку для элемента управления GameIterationTimer мы выставили свойство Interval, равное 10. 10 миллисекунд - это очень быстро, для сравнения в одной секунде 1000 миллисекунд.

В этом методе у нас определены 2 функции, которые возвращают bool-значение (тип данных Func<bool> и Func<int, bool>). Названия переменных для этих функций - funcIsNeedToContinueWhenGameIsOver и funcIsNeedToRepeatAfterWin. Обе функции мы передаём в игровой движок - в его метод HandleGameCycle. Первая функция будет вызвана, когда игра проиграна, и нужно вывести диалоговое окно для игрока с вопросом - нужно ли повторить игру? Вторая - аналогично, но предназначена для диалогового окна, оповещающего игрока о победе (это произойдет, когда игрок дойдет до 7го уровня игры). Последней строкой метода-обработчика для таймера идёт вызов Invalidate(). Это очень важный момент: фактически мы перерисовываем нашу главную форму раз в 10 миллисекунд, что равно скорости одного "игрового цикла" в нашей игре. И за это время объекты успевают изменить своё состояние, видимость и так далее.

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

Часть 6. Запускаем и тестируем разработанную игру.

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

Ещё раз напомню управление в нашей игре, если оно забылось с момента его описания в начале статьи:

Ну, а теперь попробуйте пройти разработанную в статье игру и дойти до максимального уровня, одержав победу 🙂 Я признаюсь честно, у меня не получилось это сделать с первого раза! 😄 Несмотря на простоту самой игры, на пятом и шестом уровне приходилось изрядно стараться, чтобы при уменьшающейся с каждым новым уровнем размере платформы и ускоряющемся шарике успевать его отбивать. Также в текущей реализации игры игрок сразу проигрывает при пропуске хотя бы одного шарика. Это может быть сложно, поэтому одним из вариантов развития/улучшения игры вижу возможность наделить игрока некоторыми "бонусами", например, бонусом "дополнительная жизнь", который при пропуске шарика бы не приводил к моментальному проигрышу, а позволил шарику просто отскочить от нижнего края платформы, при этом отняв у игрока сам бонус (т.е. использовав этот бонус).

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

Заключение. Возможные варианты улучшения разработанной игры.

Ниже перечислю те варианты улучшения игры, которые я придумал, возможно какие-то из них принесут вам энтузиазм и желание поэкспериментировать с исходным кодом игры:

Одним из вариантов подключения к игре звукового сопровождения может являться подготовка интересных вам звуков в формате .WAV и использования стандартного класса SoundPlayer, который есть в пространстве имён System.Media. Подробнее про этот класс можно прочитать в официальной документации на него,  там же есть и пример использования для проигрывания звуковых файлов. Куда можно было бы подключить звуковое сопровождение для разработанной игры - это обработчики различных событий от шарика в классе GameEngine игрового движка:

        private void Ball_InitPositiveGameAction(object sender, EventArgs e) {
            gameStats.IncrementGameCounter(GAME_STATS_PUSHED_AWAY_BALLS_TOTAL);
            gameStats.IncrementGameCounter(GAME_STATS_PUSHED_AWAY_BALLS_CURRENT);
        }

        private void Ball_InitIncrementNumberOfFailures(object sender, EventArgs e) {
            gameStats.IncrementNumberOfFailures();
        }

        private void Ball_CollapsedWithOtherObjects(object sender, ICollection<GameObject> destroyedBlocks) {
            Blocks.RemoveAll(block => destroyedBlocks.Contains(block));
        }

То есть, при разбитии блока может издаваться один звук, при отскоке шарика от платформы - другой, при проигрыше игрока - играет грустная мелодия в стиле "Game Over".

Как видите, есть различные способы развить описанную в статье игру и модернизировать её на свой вкус.

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

Ссылка на архив с готовым решением разработанной игры:

https://allineed.ru/our-products/download/3-allineed-ru-games/28-csharp-arkanoid-game-demo