Практикум: 8‑я часть гайда по ООП
2026-02-21 10:12 Diff

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

  • 17 фев 2020
  • 0

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

 vlada_maestro / shutterstock

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

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

Бесплатный курс по Python ➞
Мини-курс для новичков и для опытных кодеров. 4 крутых проекта в портфолио, живое общение со спикером. Кликните и узнайте, чему можно научиться на курсе. Смотреть программу