Код
#Руководства

Практикум: 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#. Там вы напишете столько классов, что ООП станет вашей второй кожей и вы сможете мастерски его использовать.

Больше интересного про код в нашем телеграм-канале.  Подписывайтесь!

Изучайте IT на практике — бесплатно

Курсы за 2990 0 р.

Я не знаю, с чего начать
Освойте топовые нейросети за три дня. Бесплатно
Знакомимся с ChatGPT-4, DALLE-3, Midjourney, Stable Diffusion, Gen-2 и нейросетями для создания музыки. Практика в реальном времени. Подробности — по клику.
Узнать больше
Понравилась статья?
Да

Пользуясь нашим сайтом, вы соглашаетесь с тем, что мы используем cookies 🍪

Ссылка скопирована