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

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

User Rating: 0 / 5

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

Всем привет.

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

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

  • должен уже иметь некоторый опыт разработки программ на C# и понимать основные принципы объектно-ориентированного программирования (наследование, инкапсуляция, полиморфизм), а также быть знакомым с понятием классов и интерфейсов в C#, включая понятие универсальных методов, классов и интерфейсов в C#
  • должен понимать различия в модификаторах доступа public, protected, private, которые используются в языке C# и применяются к методам, полям и свойствам классов
  • должен иметь опыт создания проектов в среде Microsoft Visual Studio с типом проекта "Приложение Windows Forms (.NET Framework)", а также понимать, где и как настраиваются свойства формы и её элементов управления, а также что такое события и как создавать обработчики событий
  • должен быть знаком с понятием действий (Action) и классом Func, доступном в C#, а также понимать принцип работы лямбда-выражений
  • должен понимать, как работают события (event) и что такое делегаты (delegate) в языке C#
  • должен быть знаком с UML-нотацией и понимать диаграмму классов на UML
  • должен в общих чертах понимать, что такое GDI+ и основные принципы обработки события Paint, работу с классами Pen, Brush, Font, Graphics 

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

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

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

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

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

Требования к игре и основной геймдизайн:
  • это 2D-игра, обычное приложение Windows Forms, для программирования графики в игре мы будем обрабатывать событие Paint главной формы и рисовать графические примитивы (прямоугольники, эллипсы и работать со шрифтами для вывода различной информации игроку).
  • при старте игры игроку будет дана возможность управлять движущейся платформой, которая должна отбивать летающий по игровому полю шарик. Шарик умеет отталкиваться от "стен" и изменять направление своего движения под углом 45 градусов. Под "стенами" в игре мы будем понимать края главной формы нашего приложения. Под игровым полем мы будем понимать видимую часть формы, на которой будут располагаться все игровые объекты, доступные в игре.
  • мы предусмотрим в игре различного рода статистику: количество побед, количество поражений (т.е. "пропущенных шариков"), количество отбитых шариков, количество разбитых блоков и другие показатели, которые будут описаны ниже.
  • основное управление в игре будет осуществляться мышью - движение мышью влево вызывает движение платформы игрока влево, движение вправо - заставляет платформу двигаться вправо. Нажатие правой кнопки мыши будет ставить игру на паузу, если игроку нужен будет перерыв. Нажатие на центральную кнопку мыши ("колёсико") будет включать/выключать отображение игровой статистики. Мы также поддержим несложное управление нашей игрой с клавиатуры:
    • клавиша S - будет аналогом нажатия на колёсико мыши и будет включать/выключать отображение статистики в игре
    • клавиша пробела - будет аналогом нажатия на правую кнопку мыши и поставит игру на паузу или снимет с паузы
    • клавиша Esc - для выхода из игры
  • игроку предстоит разбивать прямоугольные блоки, расположенные в верхней части формы, управляя движущейся платформой и отбивая летающий по полю шарик. Каждый из прямоугольных блоков имеет свой собственный цвет и определённое количество попаданий по нему, чтобы его разбить:
    • сиреневые блоки - одно попадание шарика разбивает блок
    • синие блоки - необходимо будет попасть 2 раза, чтобы разбить их
    • коричневые блоки - будут требовать трёх попаданий в них шариком, чтобы разбить их
  • когда игрок разобьёт все видимые на поле блоки, то произойдёт переход на следующий уровень. Всего в игре мы предусмотрим 7 уровней сложности. При увеличении уровня в игре будет меняться количество и тип видимых блоков, а также движение шарика будет ускоряться, а размер движущейся платформы - уменьшаться по ширине, усложняя процесс игры для достижения последнего, финального уровня
  • левый, правый и верхний края формы будут безопасны для игрока, от них летающий шарик будет просто отскакивать. Нижний же край формы - при достижении его шариком - является признаком проигрыша. То есть основная цель игры - это разбить все блоки, дойдя до последнего уровня сложности в игре, при этом ни разу не пропустив летающий по полю шарик к нижней границе игрового поля.
Ограничения к игре:
  • в игре не будет предусмотрен счёт для игрока. При желании доработать и улучшить игру вы сможете ввести понятие "счёт" самостоятельно.
  • наша игра будет без музыки и без каких-либо звуковых эффектов, хотя в конце статьи я покажу один из вариантов, как можно будет улучшить игру, подключив к ней простое звуковое сопровождение.
  • режим двух игроков и игра по сети не будут поддержаны. это игра только для одного игрока.
  • игра будет работать только в режиме небольшого окна (размер главной формы будет 438 пикселей по ширине и 559 пикселей по высоте). Полноэкранный режим не будет реализован.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • IDiagonalMovingDirection - интерфейс будет описывать одно из возможных движений игрового объекта по диагонали: вверх-влево, вверх-вправо, вниз-влево и вниз-вправо. Этот интерфейс будет использоваться для описания траектории движения шарика.
  • IHorizontalMovingDirection - будет описывать возможные направления движения по горизонтали. В игре этот интерфейс не будет использоваться, но я включу его для читателей, желающих развить игру и включить в геймплей какие-то объекты, которые будут двигаться по горизонтали
  • IVerticalMovingDirection - аналогичен предыдущему, но будет описывать возможные направления движения по вертикали. В игре также интерфейс не будет использоваться, включён для дальнейших возможных улучшений игры.
  • IHorizontalAndVerticalMovingDirection - пустой интерфейс-наследник от интерфейсов IHorizontalMovingDirection и IVerticalMovingDirection, также не будет использоваться в игре и предназначается для описания направлений движения тех игровых объектов, которые будут уметь двигаться по вертикали и горизонтали.
"Простое диагональное движение" (Класс SimpleDiagonalMovingDirection, реализующий интерфейс IDiagonalMovingDirection)

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

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

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

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

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

  • IDiagonalMovingGameObject - будет описывать игровой объект, умеющий двигаться по диагоналям. Он будет использоваться для летающего по полю шарика.
  • IBouncingDiagonalMovingGameObject - интерфейс-наследник от интерфейса IDiagonalMovingGameObject. Будет описывать "отскакивающий" игровой объект (т.е. отскакивающий либо от краёв игрового поля, либо от других игровых объектов). Именно этот интерфейс будет реализован у нас классом, описывающим летающий по полю шарик.
  • IDirectMovingGameObject - будет добавлен в пример проекта, но в игре не будет использоваться. Предназначен для тех игровых объектов, которые будут уметь двигаться по вертикали и горизонтали (в игре таких объектов у нас не будет, интерфейс предназначен для будущих улучшений игры).
"Позиция стены" (Перечисляемый тип WallPosition)

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

  • NoWall - нет стены
  • WallFromTheLeft - стена слева
  • WallFromTheRight - стена справа
  • WallFromTheTop - стена сверху
  • WallFromTheBottom - стена снизу

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • BackColor: Black
  • FormBorderStyle: FixedSingle
  • Text: [Allineed.Ru] Пример Arkanoid игры
  • Size: 438; 559
  • StartPosition: CenterScreen
  • MaximizeBox: False
  • MinimizeBox: False

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

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

  • Engine
  • GameObjects
    • Instances
    • MovingDirection
    • Positioning
  • Graphics
  • Statistics

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

Часть 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-типа:

  • NoWall - нет никакой стены
  • WallFromTheLeft - есть стена слева
  • WallFromTheRight - есть стена справа
  • WallFromTheTop - есть стена сверху
  • WallFromTheBottom - есть стена снизу

Напомню, под "стеной" в нашей игре мы понимаем фактически границы игрового поля, т.е., проще говоря, границы главной формы приложения. Как видим, 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();
    }
}

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

  • IsNotMoving - вернёт true, если игровой объект в данный момент времени не двигается (неподвижен)
  • InitRandomDirection - инициирует движение игрового объекта в произвольном направлении движения - среди тех направлений, которые этот игровой объект умеет поддерживать
  • InitRandomSafeDirection - метод аналогичен InitRandomDirection, но с ограничениями: он инициирует движение игрового объекта в произвольном безопасном направлении. Под безопасным направлением в нашей игре мы будем понимать такое направление движения объекта (летающего шарика), которое даёт возможность и время игроку включиться в игру. Дело в том, что когда игра начнётся, то в случае, если шарик сразу полетит куда-то вниз в сторону игрока, это может быть непривычно с первого раза и внезапно можно сразу проиграть игру. Когда же летающий шарик полетит в безопасном для игрока направлении (вверх-влево или вверх-вправо), то это даёт игроку немного времени включиться в игру, понять управление и смысл происходящего.

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

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

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

  • IsMovingUp - вернёт true, если игровой объект в данный момент времени двигается вверх, иначе вернёт false
  • IsMovingDown - вернёт true, если игровой объект в данный момент времени двигается вниз, иначе вернёт false

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

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

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

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

  • IsMovingLeft - вернёт true, если игровой объект в данный момент времени двигается влево, иначе вернёт false
  • IsMovingRight - вернёт true, если игровой объект в данный момент времени двигается вправо, иначе вернёт false

Следующий интерфейс - это 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();
    }
}

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

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

  • IsMovingUpRight - вернёт true, если игровой объект в данный момент времени двигается вверх-вправо, иначе вернёт false
  • IsMovingUpLeft - вернёт true, если игровой объект в данный момент времени двигается вверх-влево, иначе вернёт false
  • IsMovingDownRight - вернёт true, если игровой объект в данный момент времени двигается вниз-вправо, иначе вернёт false
  • IsMovingDownLeft - вернёт true, если игровой объект в данный момент времени двигается вниз-влево, иначе вернёт false
  • ChangeDirectionToUpLeft - метод изменит направление движения игрового объекта на "вверх-влево" (независимо от текущего направления движения игрового объекта)
  • ChangeDirectionToUpRight - метод изменит направление движения игрового объекта на "вверх-вправо" (независимо от текущего направления движения игрового объекта)
  • ChangeDirectionToDownLeft - метод изменит направление движения игрового объекта на "вниз-влево" (независимо от текущего направления движения игрового объекта)
  • 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();
    }
}

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

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

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

  • делегат с именем MultipleGameObjectsInteractionDelegate - описывает некоторый метод, который на вход принимает коллекцию игровых объектов - ICollection<GameObject> otherObjects. Этот делегат будет использоваться для события CollapsedWithOtherObjects, которое обозначает следующее действие: "игровой объект столкнулся с несколькими другими игровыми объектами". В нашей игре это событие будет вызывать летающий шарик, когда он попадёт сразу в несколько блоков. У нас может быть случай, что шарик будет попадать сразу по двум статичным блокам, стоящим рядом друг с другом.
  • событие InitIncrementNumberOfFailures - будет вызываться игровым объектом, уведомляя игровой движок (о нём будет рассказано ниже по тексту), что следует увеличить количество поражений. Летающий шарик, как мы увидим далее, "поймет" при определённых условиях, что он достиг нижней границы игрового поля, и это означает, что игрок проиграл. В этот момент шарик будет инициировать это событие для игрового движка.
  • событие InitPositiveGameAction - будет вызываться игровым объектом, когда в игре произошло позитивное игровое действие. Позитивное - для игрока. У нас таким действием будет очередное отбивание игроком шарика, а по событию мы сможем в игровом движке увеличить счётчик отбитых шариков в игровой статистике.
  • абстрактный метод GetObjectRectangle. Поскольку наш класс GameObject сам является абстрактным, то он может иметь и абстрактные методы, т.е. это методы, не имеющие "тела" (или не реализованные) в самом классе, но они должны (обязаны) быть в этом случае реализованы в дочерних классах, наследующихся непосредственно от GameObject. Этот метод будет в дочерних классах регулировать, как именно вычислить прямоугольник, описанный вокруг нашего игрового объекта. Это будет необходимо на стадии отрисовки всех игровых объектов на игровом поле, поскольку нам будет нужно знать и позицию игрового объекта, и его ширину и высоту.

Теперь мы создадим в каталоге 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() - просто возвращает половину ширины прямоугольного объекта. Он нам пригождается в двух других методах класса:

  • SetPositionCenteredHorizontally - метод вызывается для центрирования платформы игрока относительно текущей координаты X курсора мыши. От координаты X курсора мыши мы вычитаем половину ширины платформы, чтобы платформа всегда была центрирована относительно курсора.
  • CanMoveToPointWhenCentered - метод вызывается, когда нужно понять, - может ли платформа двигаться в заданную точку point, будучи центрированной относительной этой точки. Точкой же point снова будет выступать координаты курсора мыши. Метод нужен, чтобы платформа "не уезжала" за края игрового поля влево и вправо, а просто останавливалась, когда "упрётся" в левый или правый край игрового поля. То есть метод вычисляет, может ли платформа игрока двигаться левее/правее допустимых пределов игрового поля по горизонтали. Если да, то метод вернёт true, иначе он вернёт false

Теперь в этом же каталоге 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) {
        }
    }
}

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

  • BorderColor - задаёт цвет границы для отрисовки блока
  • BodyColor - задаёт цвет заливки для блока
  • HitsToDestroy - задаёт количество ударов по блоку летающим шариком, чтобы уничтожить этот блок
  • CurrentHits - задаёт текущее количество произведённых ударов по блоку летающим шариком

Теперь мы напишем класс 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 (описание вышло довольно объёмное, если вам проще понять логику по коду самого метода, - пропускайте комментарии для понятных вам методов):

  • Rectangle GetObjectRectangle() - является реализацией абстрактного метода из родительского класса GameObject. Этот метод возвращает новый экземпляр класса Rectangle, представляющий собой описанный вокруг шарика прямоугольник, который на самом деле будет квадратом, поскольку радиус шарика везде одинаковый. Для создаваемого объекта Rectangle в первых двух аргументах конструктора передаются X и Y (текущие координаты шарика), а в двух других - ширина и высота описанного квадрата, которая равна диаметру шарика, т.е. 2 * <радиус_шарика>.
  • void MoveUpRight() - это реализация метода для интерфейса IDiagonalMovingGameObject. Реализует движение шарика в направлении "вверх-вправо". Движение вправо достигается за счёт того, что к координате X шарика мы прибавляем его текущую скорость, а из координаты Y - вычитаем его текущую скорость. Почему вычитаем? Потому что ось Y пролегает от левого-верхнего края главной формы и идёт вниз. Поэтому движение шарика вверх - это по сути уменьшение координаты Y в сторону меньших значений.
  • void MoveUpLeft() - это реализация метода для интерфейса IDiagonalMovingGameObject. Реализует движение шарика в направлении "вверх-влево". Аналогично предыдущему методу: движение шарика влево производится за счёт вычитания из координаты X шарика значения его текущей скорости. А поскольку движемся вверх, то также из координаты Y шарика отнимаем его текущую скорость.
  • void MoveDownLeft()  - это реализация метода для интерфейса IDiagonalMovingGameObject. Реализует движение шарика в направлении "вниз-влево". Аналогично предыдущему методу: движение шарика влево производится за счёт вычитания из координаты X шарика значения его текущей скорости. А поскольку движемся вниз, то к координате Y шарика прибавляем его текущую скорость.
  • void MoveDownRight()  - это реализация метода для интерфейса IDiagonalMovingGameObject. Реализует движение шарика в направлении "вниз-вправо". Аналогично предыдущему методу: движение шарика вправо производится за счёт прибавления к координате X шарика значения его текущей скорости. А поскольку движемся вниз, то к координате Y шарика прибавляем его текущую скорость.
  • void InitRandomDiagonalMovingDirection() - это реализация метода для интерфейса IDiagonalMovingGameObject. Метод создаёт новый экземпляр класса SimpleDiagonalMovingDirection, отвечающего за диагональное движение для шарика. Затем вызывает у этого экземпляра метод InitRandomDirection(), чтобы вычислить произвольное направление движения шарика - в любых направлениях. И в конце устанавливает это вычисленное направление движения в качестве текущего направления движения шарика.
  • void InitRandomSafeDiagonalMovingDirection() - это реализация метода для интерфейса IDiagonalMovingGameObject. Метод аналогичен предыдущему, отличие лишь в том, что он предназначен для расчёта и установки безопасного направления движения шарика. Безопасное - это "вверх-влево" или "вверх-вправо", главное, чтобы не вниз, поскольку это сразу требует от игрока должной быстрой реакции в начале игры.
  • void MoveAtCurrentDirection() - это реализация метода для интерфейса IMovingGameObject<T>. Всё, что делает метод, - это проверяет, в каком сейчас направлении двигается шарик и, в зависимости от этого, вызывает один из 4-х доступных методов: MoveUpLeft (меняет позицию шарика для движения "вверх-влево"), MoveUpRight (меняет позицию шарика для движения "вверх-вправо"), MoveDownLeft (меняет позицию шарика для движения "вниз-влево"), MoveDownRight (меняет позицию шарика для движения "вниз-вправо").
  • bool IsCollisionWithBounceFromObject(int newX, int newY) - это реализация метода для интерфейса IBouncingDiagonalMovingGameObject. Метод проверит, не столкнулся ли шарик с платформой игрока и вернёт true, если столкнулся (в противном случае - false).
  • bool IsCollisionWithDestroyingObjects(int newX, int newY) - это реализация метода для интерфейса IBouncingDiagonalMovingGameObject. Метод проверяет - случилось ли столкновение шарика со статичными блоками? (т.е. попадание шариком по ним).  Если случилось, метод вернёт true, в противном случае он вернёт false. Алгоритм этой проверки: задаём новый список destroyedBlocks, куда будут помещаться блоки, по которым шарик попал. Далее необходимо пробежать циклом по всем существующим на игровом поле статичным блокам и проверить через сравнение координат X и Y шарика пересечение с площадью каждого статичного блока (с учётом его координат X и Y и размера каждого блока). Также при попадании по блоку при помощи свойств блока CurrentHits и HitsToDestroy происходит вычисление того - пора ли блоку уничтожиться или же просто увеличить количество ударов по нему шариком? Если блок "выживает" и просто накапливает количество попаданий по нему, то он не попадает в список destroyedBlocks. В конце же метода необходимо сделать 3 вещи:
    • 1) Вызвать событие OnCollapsedWithOtherObjects, передав в его параметре игровому движку все уничтоженные блоки;
    • 2) Удалить из списка уничтожаемых блоков destroyingStaticBlocks, контролируемого объектом шарика, все статичные блоки, что были уничтожены;
    • 3) Установить поле wallPosition у шарика в значение WallFromTheTop, что приведёт к эмуляции столкновения с верхней "стеной". Понятно, что до верхней "стены" шарик не долетел - он лишь попал в блоки. Но поскольку блоки в игре всегда расположены у верхней границы поля, мы эмулируем просто отскок от верхней стены, чтобы метод Bounce() при его ближайшем вызове изменил направление шарика на нужное нам.
  • bool CanMoveAtCurrentDirection(int lowerBoundX, int upperBoundX, int lowerBoundY, int upperBoundY, int upperBoundXDelta, int upperBoundYDelta) - это реализация метода для интерфейса IMovingGameObject<T>. Метод будет вычислять различные условия, которые ответят на главный вопрос: "может ли шарик продолжить беспрепятственно двигаться с его текущей скоростью в текущем направлении его движения?". Если да, то метод должен вернуть true, иначе метод должен вернуть false. Этот метод, несмотря на занимаемое количество строк кода, устроен по несложному принципу: он проверяет в развилках условий if - else if текущее направление движение шарика и внутри каждого такого условия присваивает переменным newX и newY новые потенциальные координаты X и Y шарика, которые бы получились при его движении с текущей скоростью. При движении в нижнем направлении у нас потенциально может встретиться платформа игрока, а при движении в верхнем направлении - статичные блоки. Поэтому, вычислив потенциальные координаты шарика, мы их используем, чтобы проверить, не произойдет ли в этих новых координатах пересечения (т.е. по сути столкновения) с игровыми объектами (с платформой игрока - снизу, со статичными блоками - сверху, а также со "стенами" - по краям игрового поля). Метод обязательно должен вернуть true, если никаких пересечений/столкновений по новым координатам newX и newY для шарика не случается. А если случается - должен вернуть false.
  • void BounceWhenMovingUpLeft() - закрытый метод класса, который будет вызываться из основного метода Bounce(), реализующего логику отскока шарика. Метод меняет направление движение шарика, если он двигался "вверх-влево". Там возможны 2 ситуации, которые мы разберём ниже на картинке, чтобы лучше понять логику отскока шарика (см. 1-й случай на картинке ниже и возможные траектории отскока).
  • void BounceWhenMovingUpRight() - закрытый метод класса, который будет вызываться из основного метода Bounce(), реализующего логику отскока шарика. Метод меняет направление движение шарика, если он двигался "вверх-вправо". Там также возможны 2 ситуации, которые мы разберём ниже на картинках, чтобы лучше понять логику отскока шарика. (см. 2-й случай на картинке ниже и возможные траектории отскока).
  • void CheckForCollisionWithPlatform(Action actionIfCollisionHappened) - закрытый метод класса, который проверит, произошло ли столкновение с платформой игрока, и если это так, то выполнит предписанное действие actionIfCollisionHappened. Этот метод будет вызываться из двух других методов, которые описаны ниже, а в качестве действия - будет передаваться лямбда-выражение, изменяющее направление движения шарика, в зависимости от его текущего направления движения. Например, если шарик летел "вниз-влево", то отскок от платформы игрока поменяет направление на "вверх-влево" (угол отскока равен 45 градусов). Аналогично, если шарик летел "вниз-вправо", то изменим направление на "вверх-вправо", что также образует угол отскока в 45 градусов.
  • void CheckForCollisionWithPlatformAndBounceWhenMovingDownLeft() - закрытый метод класса, который проверит столкновение с платформой игрока в ситуации, когда шарик двигался "вниз-влево"
  • void CheckForCollisionWithPlatformAndBounceWhenMovingDownRight() - закрытый метод класса, который проверит столкновение с платформой игрока в ситуации, когда шарик двигался "вниз-вправо"
  • void Bounce() - это реализация метода для интерфейса IBouncingDiagonalMovingGameObject. Метод выполняет необходимые действия шарика для осуществления отскока от "стен" или платформы игрока. Отскок от статичных уничтожающихся блоков будет у нас эмулироваться и представляться как столкновение с верхней "стеной", с одновременным уничтожением тех блоков, по которым попал шарик.
  • void SetWallFailureConstraint(WallPosition failureWallConstraint) - это реализация метода для интерфейса IMovingGameObject<T>. С его помощью мы устанавливаем для шарика конкретное значение из enum-типа WallPosition, которое представляет собой одну из стен игрового поля, достижение которой является признаком поражения в игре.
  • bool ReachedWallFailureConstraint() - это реализация метода для интерфейса IMovingGameObject<T>. Метод возвращает true, если достигнута нижняя "стена", т.е. нижняя граница игрового поля, в противном случае возвращает false. Метод будет вызываться в тот момент, когда шарик не может продолжать движение в его текущем направлении: с его помощью мы поймем причину этого - была ли достигнута какая-то из безопасных для игрока "стен" (левая, правая или верхняя) или же шарик достиг нижней "стены"?
  • void SetMovingSpeed(int speed) -  это реализация метода для интерфейса IMovingGameObject<T>. Метод устанавливает текущую скорость шарика и будет вызываться в момент инициализации игры (будет установлена скорость шарика по умолчанию равная 4), а также в момент повышения уровня игры, т.е. шарик будет двигаться быстрее с каждым новым уровнем.
  • int GetMovingSpeed() -  это реализация метода для интерфейса IMovingGameObject<T>. Метод возвращает текущую скорость шарика и будет вызываться в момент, когда будет повышаться уровень игры. Нам будет нужно с увеличением уровня увеличить также скорость шарика, для этого будет нужна его текущая скорость.
  • void SetBounceFromObject(GameObject gameObject) - это реализация метода для интерфейса IBouncingDiagonalMovingGameObject. В него при инициализации игровых объектов в самом начале игры мы передадим экземпляр платформы игрока
  • void ResetReachedWallFailureConstraint() - это реализация метода для интерфейса IMovingGameObject<T>. В методе мы просто сбрасываем значение внутреннего поля reachedFailureConstraint для класса в значение false. Это означает, что нижняя граница ("стена") игрового поля вновь не достигнута. Метод будет вызываться в двух ситуациях:
    • игра была проиграна, но игрок в диалоговом окне выбрал опцию попробовать сыграть заново, поэтому нам нужно сбросить признак достижения нижней границы игрового поля
    • из кода метода RestartGame игрового движка, который будет вызываться в случае нажатия клавиши Enter, если игра завершена (с победой или проигрышем), но игрок решил сделать перерыв и сразу не стал продолжать.
  • void InitRandomMovingDirection() - это реализация метода для интерфейса IMovingGameObject<T>. Поскольку наш шарик является подтипом этого интерфейса, он обязан реализовать метод. Как видно по коду метода, он просто делегирует исполнение, вызывая другой метод InitRandomDiagonalMovingDirection, поскольку шарик не умеет двигаться в других направлениях, кроме диагональных.
  • IDiagonalMovingDirection GetMovingDirection() - метод для получения экземпляра класса SimpleDiagonalMovingDirection, который реализует интерфейс IDiagonalMovingDirection. Для нашей игры метод вызываться не будет, он просто является геттером для соответствующего сеттера SetMovingDirection.
  • void SetMovingDirection (IDiagonalMovingDirection movingDirection) - метод будет вызываться при первичной инициализации движения шарика по игровому полю, и в него мы будем передавать экземпляр класса SimpleDiagonalMovingDirection, который мы создали ранее. В дальнейшем, когда шарик должен будет изменить направление своего движения, мы будем вызывать соответствующие методы для этого экземпляра класса SimpleDiagonalMovingDirection, тем самым меняя направление шарика.
  • void SetBounceFromDestroyingObjects (List<GameObject> destroyingGameObjects) - этот метод мы будем вызывать после создания статичных блоков для каждого из уровней игры и передавать список уничтожаемых статичных блоков объекту шарика.

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

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

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

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

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

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

  • если шарик летел вниз в направлении вниз-влево, он отскочит вверх-влево (угол 45° от платформы)
  • если шарик летел вниз в направлении вниз-вправо, он отскочит вверх-вправо (угол 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();
                }
            }
        }
    }
}

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

  • void GenerateBlocksForCurrentLevel() - метод генерирует статические, уничтожаемые игроком блоки для каждого из возможных уровней игры. В нём расположен оператор switch, ориентированный на генерацию блоков для уровней игры от 1 до 6, поскольку сразу при достижении уровня 7 игра заканчивается победой игрока.
  • void InitGameObjectsPositionsAndState() - метод предназначен для инициализации положения и состояния игровых объектов перед стартом игры. Сначала вызовом метода ResetObjectsPositions() мы сбрасываем все позиции объектов в исходные, затем мы добавляем нужные нам игровые счётчики для фиксации игровой статистики. Далее мы подписываемся на события CollapsedWithOtherObjects, InitIncrementNumberOfFailures, InitPositiveGameAction летающего шарика. Затем, вызывая bouncingBall.SetBounceFromObject(platform), мы устанавливаем шарику объект-платформу игрока в качестве специального объекта, от которого шарик может отталкиваться. Вызов GenerateBlocksAndSetThemAsDestroyingObjects(bouncingBall) нужен для генерации блоков для текущего уровня и установки их в качестве уничтожаемых блоков для объекта-шарика. Вызов InitializeBallMovement(bouncingBall) инициирует и устанавливает текущее направление движения для шарика и устанавливает ему начальную скорость. Наконец, вызов bouncingBall.SetWallFailureConstraint(WallPosition.WallFromTheBottom) установит нижнюю "стену" игрового поля для шарика в качестве признака поражения в игре при её достижении шариком.
  • void Ball_InitPositiveGameAction(object sender, EventArgs e) - метод-обработчик для события InitPositiveGameAction, которое может быть получено от шарика. Это позитивное действие в игре, которое выражается в отбитии шарика игроком и увеличивает положительную статистику отбитых шариков в текущей игре и во всех играх.
  • void Ball_InitIncrementNumberOfFailures(object sender, EventArgs e) - метод-обработчик для события InitIncrementNumberOfFailures, которое может быть получено от шарика. Это событие возникает в момент поражения в игре, т.е. когда шарик достигает нижней "стены". Обработка заключается в одной строке кода, где мы просто увеличиваем в игровой статистике количество поражений игрока.
  • void Ball_CollapsedWithOtherObjects(object sender, ICollection<GameObject> destroyedBlocks) - метод-обработчик для события CollapsedWithOtherObjects, которое может быть получено от шарика. Событие случается, когда шарик сталкивается с другими объектами, а именно с уничтожаемыми статичными блоками, которые передаются в параметре события destroyedBlocks. Реакция на событие - в одной строке кода: нужно просто из всех текущих блоков в объекте игрового движка удалить уничтоженные игроком блоки.
  • void GenerateBlocksAndSetThemAsDestroyingObjects(IBouncingDiagonalMovingGameObject bouncingBall) - метод вызывает ранее описанный GenerateBlocksForCurrentLevel() и далее создаёт список типа List<GameObject>, помещая в него сгенерированные блоки для текущего уровня. В последней строке кода для метода эти блоки устанавливаются для шарика в качестве уничтожаемых объектов в игре: bouncingBall.SetBounceFromDestroyingObjects(destroyingBlocks)
  • void ResetObjectsPositions() - метод сбрасывает позиции платформы игрока и шарика в исходные: платформа - внизу игрового поля, с отступом bottomMargin от нижнего края формы, отцентрированная по горизонтали. Шарик - в самом центре игрового поля.
  • void InitializeBallMovement(IBouncingDiagonalMovingGameObject bouncingBall) - метод состоит из двух строк кода и снабжён комментариями в самом коде, поэтому лишь скажу, что он инициализирует движение шарика.
  • void ResetGame() - метод осуществляет сброс различных показателей игры: сброс для счётчика отбитых шариков в текущей игре в значение 0, текущий уровень - в значение 1, а скорость шарика - в стартовую скорость BALL_STARTING_SPEED, равную 4. Он также осуществляет сброс текущих позиций игровых объектов, вызывая ResetObjectsPositions(), а также генерирует блоки для 1-го уровня игры и инициирует первичное движение шарика.
  • void HandleMouseMove(Point mouseCursorLocation) - метод обрабатывает событие движения мышью в игре. Во входном параметре mouseCursorLocation - текущая позиция курсора в момент движения мышью. Что мы должны сделать - это проверить - а может ли платформа игрока передвинуться за курсором мыши, причём не "вылетая" за левый или правый край игрового поля? Если может, то мы двигаем платформу за мышью, при этом центрируя её относительно курсора мыши.
  • void CheckForGameLevelIncrease() - метод движка проверяет, не пора ли увеличить уровень игры? В его логику заложено условие, что не пора увеличивать, пока ещё не уничтожены все блоки на текущем уровне. Если же они уничтожены, то нужно перебрать все возможные уровни игры в цикле, а внутри цикла сравнить текущий уровень игры со счётчиком цикла. Если они равны, то это является признаком того, что нужно выполнить нужные нам действия, связанные с увеличением уровня игры. В нашем случае мы увеличим скорость движения шарика на единицу, а вот ширину платформы игрока уменьшим на 10 пикселей, делая игру более сложной. Если вам покажется, что это многовато, можете уменьшить значение 10, а то и вовсе не изменять ширину платформы.

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

Часть 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. Программируем поведение главной формы.

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

  • Load
  • Paint
  • MouseMove
  • KeyDown
  • MouseClick

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

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

  • Enabled: False (уже будет таким по умолчанию)
  • Interval: 10
  • Name: GameIterationTimer

Дважды кликнем мышью на этом элементе таймера, чтобы в коде главной формы также сгенерировался обработчик для события 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. Запускаем и тестируем разработанную игру.

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

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

  • Правая кнопка мыши или клавиша пробела - поставить игру на паузу или снять с паузы
  • Средняя кнопка мыши или клавиша S - скрыть/отобразить зелёный текст со статистикой игры
  • Клавиша Esc - показать диалог выхода и выйти из игры. Также временно ставит игру на паузу, пока игрок решает, выйти из игры или продолжить
  • Клавиша Enter - запускает игру в том случае, если шарик не двигается по полю, а до этого был выигрыш игрока (достижение им максимального 7-го уровня сложности и разбитие всех блоков), либо же проигрыш игрока, при этом игрок сразу не захотел продолжать игру. Если же игра активна, а шарик двигается по игровому полю, то нажатие на Enter не имеет никакого эффекта

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

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

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

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

  • можно улучшить графику в разработанной игре, к примеру, вместо обычного прямоугольника рисовать какое-то готовое изображение стилизованного прямоугольника. Единственное - в этом случае, возможно, имеет смысл иметь несколько заготовленных вариантов изображений для прямоугольной платформы - для всех возможных уровней игры, поскольку, как вы помните, ширина платформы игрока постоянно уменьшается при достижении очередного уровня; аналогичным образом можно рисовать более красивый шарик, вместо обычной монотонной заливки использовать красивое изображение шарика. Также можно стилизовать статичные разбивающиеся блоки; можно сделать фон игрового поля не статичным (заливка чёрным цветом, как в статье), а наложить фоновое изображение или предусмотреть некоторую анимацию (эффекты летящих "звёзд", "дождя", "снега" - зависит от вашей фантазии).
  • можно ввести в игру какие-то горизонтально двигающиеся или вертикально двигающиеся объекты. Например, вертикально "падающие" от верхней границы игрового поля бонусы для игрока (типа бонуса "дополнительная жизнь", дающего возможность игроку не сразу проиграть, даже если он пропустит шарик к нижнему краю игрового поля). Горизонтально двигающимися объектами могут быть, к примеру, какие-то двигающиеся и неразрушаемые блоки на последних уровнях, которые усложнят игру и продвижение шарика до верхних блоков. Здесь, опять же, всё зависит от вашей фантазии.
  • можно включить в игру звуковое сопровождение, например, какую-то приятную фоновую музыку и добавить при возникновении игровых событий различные звуки: отскок шарика от платформы, разбитие очередного блока, получение игроком бонуса (если ввести в игру эти бонусы) и т. д.
  • можно предусмотреть отдельную форму настроек для игры, где перед стартом регулировать начальную скорость движения шарика, ширину платформы и т. д.
  • можно при самом старте попробовать добавить к игре небольшую заставку, выводящую название игры, а затем уже запуск игрового процесса 

Одним из вариантов подключения к игре звукового сопровождения может являться подготовка интересных вам звуков в формате .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 

 

Яндекс.Метрика