Практикум: 8‑я часть гайда по ООП
Заключительная часть серии статей про ООП, в которой мы создадим небольшой проект.


vlada_maestro / shutterstock
Поздравляю всех, кто осилил все предыдущие статьи и добрался до практикума по ООП на C#. Нам предстоит создать небольшую консольную игру. Мы будем использовать уже изученные возможности ООП, но будет и несколько новых приёмов.
Все статьи про ООП
Какой будет игра
Это будет консольная игра с символьной графикой, в которой можно перемещаться по локации, атаковать NPC, открывать инвентарь и пользоваться предметами.
Устроена игра будет так:
- При запуске вызывается метод InitGame (), в котором будут созданы игровые объекты, предметы и прочее.
- После будет вызван метод Update (), который обновляет состояние игры.
Внутри метода Update () должен находиться цикл со следующими действиями:
- Отрисовка локации или инвентаря.
- Получение нажатой игроком клавиши.
- Передача клавиши в контроллер.
- Контроллер, в зависимости от нажатой клавиши, будет вызывать методы игровых объектов: например, Move () или Use ().
Для управления игрой мы используем три статических класса-контроллера:
- LocationController — перехватывает действия игрока на локации.
- InventoryController — перехватывает действия игрока в инвентаре.
- GraphicsController — управляет выводом.
Игровые данные (размеры локации, список объектов) находятся в статическом классе Game. Объекты будут реализованы с помощью классов GameObject (базовый), Player и NPC. За расположение и перемещение по локации пусть отвечает класс Position.
Предметы реализуются с помощью классов Item (базовый), Potion и Meal. Если предмет может быть использован, то в нём реализуется интерфейс IUsable.
Проект небольшой, но если впихнуть его весь в статью, то для текста места не останется. Поэтому здесь будут только важные части. Чтобы ознакомиться с проектом полностью, скачайте весь исходный код из репозитория на GitHub.
Создание игровых объектов
Начнём с класса GameObjects — он будет родительским для Player и NPC:
public class GameObject
{
private string name;
private Position position;
private ConsoleColor color;
private int hpFull;
private int hp;
public GameObject()
{
}
public GameObject(string name, Position position, ConsoleColor color)
{
this.name = name;
this.position = position;
this.color = color;
hpFull = 100;
hp = 80;
}
public void Attack(GameObject obj)
{
obj.TakeDamage(10);
}
public void TakeDamage(int dmg)
{
this.hp -= dmg;
Console.Beep();
if(hp <= 0)
{
Die();
}
}
private void Die()
{
Console.WriteLine($"{this.Name} died");
Console.Beep();
Console.ReadKey();
Game.Objects.Remove(this);
}
public void Heal(int val)
{
if(hp + val > hpFull)
{
val = val - (hp + val - hpFull);
}
hp += val;
Console.WriteLine($"\n{name} healed {val} HP!");
Console.WriteLine("Press any key to continue...");
Console.ReadKey();
}
//Далее идут свойства, которые здесь опущены, чтобы не занимать место
}
Обратите внимание на метод Die () — он выполняется, когда у объекта кончается здоровье. Метод удаляет объект из коллекции Game.Objects, что освобождает память. Схожую функцию выполняют деструкторы — они вызываются перед тем, как объект будет удалён из памяти.
Класс NPC позволит в дальнейшем реализовать логику для игрового ИИ. Player же содержит методы для управления персонажем игрока. Например, чуть позже мы реализуем в нём использование предметов из инвентаря.
Как уже говорилось выше, созданные объекты будут храниться в коллекции статического класса Game. Вот как это выглядит:
public static class Game
{
public static bool Play = true; //Запущена ли игра
public static List<GameObject> Objects = new List<GameObject>(); //Игровые объекты
public static Player Player; //Ссылка на игрока - сам объект также будет находиться в коллекции Objects
public const int Width = 100; //Ширина локации
public const int Height = 25; //Высота локации
public static GameMode Mode = GameMode.Location; //Режим
public static int Selection = -1; //Выбранный предмет в инвентаре
}
Вы могли заметить тут тип данных GameMode — это класс перечислений, который упрощает создание списков в коде:
public enum GameMode
{
Location,
Inventory
}
Одна из альтернатив классам-перечислениям — числа. То есть мы могли бы просто написать так:
public static int Mode = 0; //0 - Локация, 1 - Инвентарь
Но это не очень удобно, потому что вам придётся всё время смотреть, какое число и для чего вы указывали.
Графика
Теперь, чтобы заставить объекты и локацию отображаться, напишем класс GraphicsController:
public static class GraphicsController
{
//Два следующих поля будут использованы для того, чтобы сохранить в себе рамки локации - вычисление количества символов при каждой отрисовке будет потреблять больше ресурсов
public static string TopLine = "";
public static string MidLine = "";
public static void Draw(List<GameObject> objects)
{
Console.Clear();
DrawBorder();
foreach(GameObject obj in objects)
{
if(obj.HP > 0)
{
Draw(obj);
}
}
Console.SetCursorPosition(0, Game.Height + 1);
}
public static void Draw(GameObject obj)
{
Console.SetCursorPosition(obj.Position.X - obj.Position.WidthHalf, obj.Position.Y - obj.Position.HeightHalf);
Console.ForegroundColor = obj.Color;
string width = "";
char symbol = ' ';
switch(obj.Position.Direction)
{
case Direction.Up:
symbol = '↑';
break;
case Direction.Down:
symbol = '↓';
break;
case Direction.Left:
symbol = '←';
break;
case Direction.Right:
symbol = '→';
break;
}
for(int i = 0; i < obj.Position.Width; i++)
{
width += symbol;
}
for(int i = 0; i < obj.Position.Height; i++)
{
Console.SetCursorPosition(obj.Position.X - obj.Position.WidthHalf, obj.Position.Y - obj.Position.HeightHalf + i);
Console.Write(width);
}
Console.ForegroundColor = ConsoleColor.White;
}
public static void DrawBorder()
{
Console.ForegroundColor = ConsoleColor.White;
InitLines();
for(int i = 0; i < Game.Height; i++)
{
if(i == 0 || i == Game.Height - 1)
{
Console.WriteLine(TopLine);
}
else
{
Console.WriteLine(MidLine);
}
}
Console.WriteLine(Game.Player.Health);
}
private static void InitLines()
{
if(TopLine == "")
{
for(int i = 0; i < Game.Width; i++)
{
if(i == 0 || i == Game.Width - 1)
{
TopLine += "+";
MidLine += "|";
}
else
{
TopLine += "=";
MidLine += " ";
}
}
}
}
}
Объекты рисуются с помощью символов, которые меняются в зависимости от того, в какую сторону направлен объект.
Теперь можно обновить класс Program, чтобы создать первые объекты и отобразить их в консоли:
class Program
{
static void Main(string[] args)
{
InitGame();
Update();
}
static void InitGame()
{
Game.Player = new Player("Hero", new Position(5, 10, 2, 2), ConsoleColor.White);
Game.Objects.Add(Game.Player);
Game.Objects.Add(new NPC("Enemy 1", new Position(10, 10, 2, 2), ConsoleColor.Red));
Game.Objects.Add(new NPC("Enemy 2", new Position(15, 10, 2, 2), ConsoleColor.Blue));
Game.Objects.Add(new NPC("Enemy 3", new Position(25, 20, 2, 2), ConsoleColor.Yellow));
Game.Objects.Add(new NPC("Enemy 4", new Position(35, 5, 2, 2), ConsoleColor.Green));
Game.Objects.Add(new NPC("Enemy 5", new Position(40, 3, 2, 2), ConsoleColor.Magenta));
}
static void Update()
{
ConsoleKeyInfo e;
while(Game.Play)
{
switch(Game.Mode)
{
case GameMode.Location:
GraphicsController.Draw(Game.Objects);
e = Console.ReadKey();
LocationController.Controll(e); //Этот метод разберём в следующем разделе
break;
}
}
}
}
Вот что должно быть выведено:

Управление
Чтобы заставить объект игрока двигаться, реализуем управление:
public static class LocationController
{
public static void Controll(ConsoleKeyInfo e)
{
Direction d = Direction.None;
switch(e.Key)
{
case ConsoleKey.UpArrow:
d = Direction.Up;
break;
case ConsoleKey.DownArrow:
d = Direction.Down;
break;
case ConsoleKey.LeftArrow:
d = Direction.Left;
break;
case ConsoleKey.RightArrow:
d = Direction.Right;
break;
case ConsoleKey.A:
GameObject obj = null;
int dY = 0;
int dX = 0;
switch(Game.Player.Position.Direction)
{
case Direction.Up:
dY = -1;
break;
case Direction.Down:
dY = 1;
break;
case Direction.Left:
dX = -1;
break;
case Direction.Right:
dX = 1;
break;
}
int tempX = Game.Player.Position.X + dX;
int tempY = Game.Player.Position.Y + dY;
obj = Game.Player.Position.GetCollision(Game.Objects, tempX, tempY);
if(obj != null)
{
Game.Player.Attack(obj);
}
break;
case ConsoleKey.Escape:
Console.SetCursorPosition(0, Game.Height + 1);
Console.WriteLine("Are you sure you want to exit? (y/n)");
e = Console.ReadKey();
Console.WriteLine("\nGood Bye!");
if(e.Key == ConsoleKey.Y)
{
Game.Play = false;
}
break;
case ConsoleKey.I:
InventoryController.Open();
break;
}
if(d != Direction.None)
{
Game.Player.Position.Move(d);
}
}
}
Этот класс проверяет нажатую игроком клавишу и передаёт команды дальше. Например, классу Position, который отвечает за перемещение объектов по локации.
public class Position
{
private int x;
private int y;
private int width;
private int height;
private int widthHalf;
private int heightHalf;
private Direction direction;
public Position(int x, int y, int width, int height)
{
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.widthHalf = width / 2;
this.heightHalf = height / 2;
direction = Direction.Up;
}
public bool Move(Direction d)
{
int dX = 0;
int dY = 0;
direction = d;
switch(d)
{
case Direction.Up:
dY = -1;
break;
case Direction.Down:
dY = 1;
break;
case Direction.Left:
dX = -1;
break;
case Direction.Right:
dX = 1;
break;
}
int tempX = x + dX;
int tempY = y + dY;
bool collided = Collide(Game.Objects, tempX, tempY);
if(collided)
{
return false;
}
else
{
this.x = tempX;
this.y = tempY;
return true;
}
}
public GameObject GetCollision(List<GameObject> objects, int tempX, int tempY)
{
GameObject obj = null;
for(int i = 0; i < objects.Count; i++)
{
if(objects[i].Position != this)
{
if(Collide(objects[i].Position, tempX, tempY))
{
obj = objects[i];
break;
}
}
}
return obj;
}
public bool Collide(List<GameObject> objects, int tempX, int tempY)
{
GameObject obj = GetCollision(objects, tempX, tempY);
if(obj == null)
{
if(
tempX - widthHalf < 1 ||
tempX + widthHalf > Game.Width - 1 ||
tempY - heightHalf < 1 ||
tempY + heightHalf > Game.Height - 1
)
{
return true;
}
else
{
return false;
}
}
else
{
return true;
}
}
public bool Collide(Position obj, int tempX, int tempY)
{
bool collided = false;
if(
tempX + widthHalf > obj.X - obj.WidthHalf &&
tempX - widthHalf < obj.X + obj.WidthHalf
)
{
if(
tempY + heightHalf > obj.Y - obj.HeightHalf &&
tempY - heightHalf < obj.Y + obj.HeightHalf
)
{
collided = true;
}
}
return collided;
}
}
Можно проверить, как работает перемещение:

А вместе с перемещением и боевую систему:

Инвентарь
Инвентарь начинается с класса Item:
public class Item
{
private string name;
public Item(string name)
{
this.name = name;
}
public string Name
{
get
{
return this.name;
}
}
}
Классы Potion и Meal практически идентичные, за исключением выводимых надписей. Поэтому здесь будет показан код только одного из классов:
public class Potion : Item, IUsable
{
private int hpVal;
public Potion(string name, int hpVal)
:base(name)
{
this.hpVal = hpVal;
}
public void Use(Player p)
{
p.Heal(hpVal);
}
public string Description
{
get
{
return $"A flask of {this.Name} restores {this.hpVal} HP.";
}
}
}
Можно заметить, что класс наследует интерфейс IUsable:
public interface IUsable
{
public void Use(Player p);
}
Теперь, чтобы пользователь мог посмотреть предметы в инвентаре, нужно добавить в GraphicsController следующий метод:
public static void DrawInventory(List<Item> items)
{
Console.Clear();
Console.SetCursorPosition(0, 0);
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("Inventory");
for(int i = 0; i < items.Count; i++)
{
Console.ForegroundColor = ConsoleColor.Gray;
if(i == Game.Selection)
{
Console.ForegroundColor = ConsoleColor.Blue;
}
Console.WriteLine(items[i].Name);
}
Console.ForegroundColor = ConsoleColor.Gray;
}
Также в классе Program добавьте в switch с режимами следующий вариант:
case GameMode.Inventory:
if(Game.Player.Inventory.Count == 0)
{
InventoryController.Close();
break;
}
GraphicsController.DrawInventory(Game.Player.Inventory);
e = Console.ReadKey();
InventoryController.Controll(e);
break;
Управление будет производиться с помощью InventoryController:
public static class InventoryController
{
public static void Controll(ConsoleKeyInfo e)
{
switch(e.Key)
{
case ConsoleKey.UpArrow:
if(Game.Selection != 0)
{
Game.Selection--;
}
break;
case ConsoleKey.DownArrow:
if(Game.Selection < Game.Player.Inventory.Count - 1)
{
Game.Selection++;
}
break;
case ConsoleKey.E:
Game.Player.Use(Game.Selection);
Game.Selection = 0;
break;
case ConsoleKey.Escape:
Close();
break;
}
}
public static void Open()
{
if(Game.Player.Inventory.Count > 0)
{
Game.Mode = GameMode.Inventory;
Game.Selection = 0;
}
else
{
Console.SetCursorPosition(0, Game.Height + 1);
Console.WriteLine("Your inventory is empty! \nPress any key to continue...");
Console.ReadKey();
}
}
public static void Close()
{
Game.Selection = -1;
Game.Mode = GameMode.Location;
}
}
Теперь самое интересное — объект Player:
public class Player : GameObject
{
private List<Item> inventory;
public Player(string name, Position position, ConsoleColor color)
:base(name, position, color)
{
inventory = new List<Item>();
}
public void Use(int index)
{
if(inventory[index] is IUsable)
{
IUsable item = inventory[index] as IUsable;
item.Use(this);
inventory.RemoveAt(index);
}
else
{
Console.WriteLine("You can't use that!");
}
}
public List<Item> Inventory
{
get
{
return this.inventory;
}
}
}
Тут вы можете увидеть два новых ключевых слова:
- is — проверяет, реализован ли в данном объекте указанный интерфейс;
- as — получает реализацию интерфейса.
Вот что получилось в итоге:

Домашнее задание
На примере небольшой игры мы посмотрели, как используются особенности объектно-ориентированного программирования, чтобы было проще писать код.
Сама игра очень маленькая, и закончить её вам предстоит самостоятельно. Ваша задача — заставить NPC двигаться по какому-нибудь паттерну. Если ИИ натыкается на игрока, то он должен атаковать, прекращая движение по паттерну и начиная преследование, пока игрок не убежит на несколько шагов.
Заключение
Надеюсь, эта серия статей была вам полезной и вы смогли разобраться, что же такое ООП, зачем и как его использовать. На этом серия заканчивается, но если вам хочется узнать больше, то можете записаться на наш бесплатный курс по C#. Там вы напишете столько классов, что ООП станет вашей второй кожей и вы сможете мастерски его использовать.
Больше интересного про код в нашем телеграм-канале. Подписывайтесь!