Пишем на C++ игру в кости

newbie_

Опубликован:  2019-07-11T12:34:21.224961Z
Отредактирован:  2019-07-11T12:34:09.799870Z
Продолжаю свой любительский практикум программирования на C++. Очередной перформанс будет посвящен разработке элементарного консольного эмулятора игры в кости. Я напишу программу, которая будет собирать данные из командной строки - имена игроков и количество бросков кубика, обрабатывать эти данные и выдавать на экран терминала результаты игры и имя победителя. Будет интересно...

1. Исходные данные задачи

Учебная задача, которую я буду сейчас решать, не нова, однажды я уже разработал подобный эмулятор игры в кости на Python3 и сейчас попробую адаптировать этот же алгоритм к языку программирования C++ с минимальными изменениями. Главная цель задачи - научиться делить исходный код программы на несколько файлов и использовать в своих программах сторонний код, разработанный другими людьми и доступный в девелоперских библиотеках Debian buster. Кроме этого, решение задачи будет основано на парадигме ООП, а значит, я планирую получить начальный элементарный опыт описания и использования объектов.

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

Игра в кости имеет простые правила:

  1. В игре участвуют два или более игроков;
  2. Каждый игрок бросает определённое количество кубиков один раз, выпавшие на каждом кубике значения одного игрока суммируются и записываются на счёт бросившего кубики игрока;
  3. После того, как все участники бросят кубики, счета всех игроков будут сравнены, победитель определяется по максимальной сумме счёта;
  4. Если максимальное количество баллов набирают два или более участников игры, они проходят в следующий тур и повторно бросают кубики до тех пор, пока не определится единственный победитель.

Разрабатываемая программа предполагает использование терминала и текстовый вывод результатов игры на экран компьютера. Описание разработки и пример игровой сессии представлены в спойлерах ниже.

2. Рабочее пространство проекта

Разработку программы я буду осуществлять на базе великолепной операционной системы Debian buster и интегрированной среды разработки Geany. Все действия предполагают использование терминала, первым делом запускаю его.

ZSOGDzLPiq.png

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

mkdir -p ~/workspace/dice
cd ~/workspace/dice

Я предполагаю, что разработанная программа вполне возможно в перспективе будет доработана, кроме этого я намерен опубликовать код программы на github.com или подобном git-сервере, следовательно, мне понадобится система контроля версий Git. Инициирую начальный git-репозиторий в только что созданном текущем каталоге.

git init .

Примечание: точка в конце команды обязательна и обозначает текущий каталог.

Примечание: Git, если всё ещё не установлен, можно установить командой sudo apt install git.

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

mkdir src

Программы на C++ требуют компиляции, необходимо предусмотреть место для хранения бинарных файлов, создаю в текущем каталоге ещё один каталог с именем bin.

mkdir bin

Чтобы бинарные файлы не попали в git-репозиторий, создаю в текущем каталоге файл с именем .gitignore - точка в начале имени файла означает, что это скрытый файл. Для создания этого файла использую консольный текстовый редактор Nano.

nano .gitignore

На текущий момент развития проекта в этом файле будет одна единственная строчка.

bin/*

jsi9OFH4OQ.png

Сохраняю изменения и выхожу из текстового редактора, в Nano сохранить изменения можно сочетанием клавиш ctrl+o, а выйти - сочетанием ctrl+x.

Создаю три файла одной командой, которая кроме этого ещё и запустит интегрированную среду разработки Geany.

geany src/ddice.h src/ddice.cpp src/dice.cpp &

dSHtVEZsSJ.png

В файле ddice.h, обычно такие файлы называют заголовочными, я буду хранить объявления констант, структур и прототипы функций и классов. В файле ddice.cpp будут храниться определения функций и методов классов. Файл dice.cpp будет содержать главную функцию и связывать все составляющие программы в единую последовательность действий, именно в этом файле будут объявлены действующие переменные и осуществлены все необходимые процедурные действия с ними.

Всё готово к разработке проекта... Приступим.

3. Обрабатываем аргументы командой строки

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

  • количество бросков одного кубика или количество кубиков в одном броске;
  • имена всех участников игры.

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

Пакетная база Debian buster - просто находка для начинающих программистов, желанная библиотека устанавливается одной простой командой.

sudo apt install -y libtclap-dev

xAwcvdmKHo.png

Перехожу в редактор Geany, открываю вкладку с файлом ddice.h и пишу следующий код.

#ifndef DDICE_H_
#define DDICE_H_

#include <string>
#include <vector>

const std::string DEFAULT = "Machine";
const unsigned int LOOP = 3;

struct args
{
  unsigned int loop;
  std::vector<std::string> names;
};

void parseArgs(int argc, char ** argv, args &);


#endif

Здесь, в теле стандартной идиомы препроцессора я объявил две константы: DEFAULT и LOOP, которые понадобятся чуть позже, при создании объектов. Далее, я объявил структуру args, в которую планирую сохранить значения обработанных аргументов командной строки. Кроме этого, я создал прототип функции parseArgs, с помощью которой будет производиться разбор аргументов командой строки. Последний параметр этой функции - ссылка на структуру типа args, а два первых параметра - аргументы командной строки.

Перехожу в Geany на вкладку файла ddice.cpp - в этом файле я воспользуюсь установленной библиотекой TCLAP и разработаю определение функции parseArgs. Пишу следующий код.

#include <iostream>
#include <tclap/CmdLine.h>
#include "ddice.h"


void parseArgs(int argc, char ** argv, args & c)
{
  try
  {
    TCLAP::CmdLine cmd("Do you want to know who is lucky?", ' ', "1.0.0");
    std::vector<unsigned int> allowed = {1, 2, 3};
    TCLAP::ValuesConstraint<unsigned int> allowedVals(allowed);
    TCLAP::ValueArg<unsigned int> loopArg(
      "n", "loop",
      "Defines how many times to throw a dice (default is 3)",
      false, 3, &allowedVals);
    cmd.add(loopArg);
    TCLAP::UnlabeledMultiArg<std::string> namesArg(
      "players", "players' names, one or more", true, "string");
    cmd.add(namesArg);
    cmd.parse(argc, argv);
    c.loop = loopArg.getValue();
    c.names = namesArg.getValue();
  }
  catch (TCLAP::ArgException & e)
  {
    std::cerr << "error: " << e.error() << " for arg "
              << e.argId() << std::endl;
  }
}

Здесь следует обратить внимание на диррективы препроцессора, которые подгружают заголовочный файл сторонней библиотеки и заголовочный файл ddice.h. Функция parseArgs при помощи инструментов библиотеки TCLAP создаёт объект cmd и два целевых объекта: loopArg и namesArg.

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

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

При помощи объекта cmd и его метода parse производится разбор переданных в командной строке аргументов, а затем полученные значения сохраняются в структуру, которая передана функции по ссылке.

Документация TCLAP рекомендует помещать код в блоки try ... catch, но, вероятно, это не обязательно. Вывод делаю потому, что мне не удалось на практике воспроизвести код в блоке catch посредством передачи в команду заведомо неверных аргументов.

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

#include <iostream>
#include "ddice.h"


int main(int argc, char ** argv)
{
  args cargs;
  parseArgs(argc, argv, cargs);
  std::cout << "Loop: " << cargs.loop << std::endl;
  for (int i = 0; i < cargs.names.size(); i++)
    std::cout << "Player #" << i + 1 << ": " << cargs.names[i] << std::endl;
  return 0;
}

Здесь я создал структуру типа args, произвёл разбор параметров командной строки при помощи функции parseArgs и сохранил полученные значения в структуру cargs. После этого я получил возможность вывести на печать заданные в командной строке параметры.

Замечание: перед тем, как перейти в терминал и откомпилировать первую версию программы, следует сохранить все файлы.

Иду в терминал и компилирую полученный код.

g++ -o bin/dice src/ddice.cpp src/dice.cpp

lb8kAT8Nps.png

Процесс компиляции завершился без ошибок и предупреждений, великолепно. Когда программа скомпилирована, можно потестировать её в ручном режиме. Начну с опции --version.

bin/dice --version

s8nBdYp3Ug.png

Опция --help.

bin/dice --help

P9ra4uAW7Q.png

Задаю имена двух игроков.

bin/dice Ustas Alex

7QitHBzUgH.png

Задаю количество бросков и имена трёх игроков.

bin/dice -n 1 Alex Felix Somebodyelse

EkQhQuHe3s.png

Ошибаюсь с аргументами.

bin/dice -n 4 Jamaica
bin/dice -n 2

bwbR9P5dFS.png

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

4. Описываем игрока

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

  1. Прототипирование функций и классов;
  2. Определение функций и методов классов;
  3. Реализация функционала в главной функции программы с помощью процедурных вызовов разработанных функций и методов.

Участника игры я опишу при помощи соответствующего класса - Player. Игрок может иметь два характерных свойства:

  • имя для идентификации;
  • счёт - количество набранных очков.

Прототип класса Player будет размещён в заголовочном файле ddice.h. Открываю этот файл в редакторе Geany и вписываю внутри стандартной идиомы препроцессора под прототипом функции parseArgs следующую декларацию класса.

class Player
{
  private:
    std::string name;
    unsigned int score;
    void set_score(const unsigned int loop);
  public:
    Player();
    Player(const unsigned int);
    Player(const std::string & s, const unsigned int loop);
    ~Player() {}
    void pprint(const unsigned int block);
    unsigned int get_score() { return score; }
    std::string get_name() { return name; }
};

vLN96scZR5.png

Класс Player в публичной части имеет три различных конструктора, метод pprint для вывода данных игрока на экран терминала, методы get_score и get_name для получения соответствующих значений свойств игрока. В приватной части класс Player содержит два свойства: name и score, а так же функцию для подсчёта количества полученных игроком баллов в результате броска заданного количества кубиков - set_score.

Определения функций-членов этого класса я помещу в файл ddice.cpp, открываю его в редакторе и дописываю в конец файла следующие определения.

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

Player::Player()
{
  name = DEFAULT;
  set_score(LOOP);
}

Второй конструктор содержит один единственный параметр, задающий количество кубиков в броске (или количество бросков одного кубика):

Player::Player(const unsigned int loop)
{
  name = DEFAULT;
  set_score(loop);
}

Третий конструктор содежит два параметра: строку, выражающую имя игрока, и количество кубиков в броске:

Player::Player(const std::string & s, const unsigned int loop)
{
  name = s;
  set_score(loop);
}

Два последних конструктора буду использованы явным образом на следующиих этапах разработки программы.

Подсчёт количества полученных игроком очков будет произведён с помощью стандартного генератора псевдослучайных чисел в функции-члене set_score:

void Player::set_score(const unsigned int loop)
{
  score = 0;
  for (int i = 0; i < loop; i++)
  {
    score += std::rand() % 6 + 1;
  }
}

Для реализации этого определения необходима следующая директива препроцессора.

#include <cstdlib>

Последнее определение - это функция-член pprint. С помощью этой функции я буду выводить на экран терминала данные игрока в заданной форме:

void Player::pprint(const unsigned int block)
{
  std::cout << name + ':';
  std::cout.width(block - name.size());
  std::cout.setf(std::ios_base::right, std::ios_base::adjustfield);
  std::cout << score << std::endl;
}

Переменная block в теле этой функции обеспечивает выравнивание вывода на терминале для нескольких игроков, когда имена игроков имеют разное количество символов.

Всё готово для реализации задуманного. Перехожу в редакторе на вкладку файла dice.cpp и переписываю главную функции программы следующим образом.

#include <iostream>
#include <cstdlib>
#include <ctime>
#include "ddice.h"


int main(int argc, char ** argv)
{
  args cargs;
  parseArgs(argc, argv, cargs);
  std::cout << "Loop: " << cargs.loop << std::endl;
  for (int i = 0; i < cargs.names.size(); i++)
    std::cout << "Player #" << i + 1 << ": " << cargs.names[i] << std::endl;
// новый код
  std::cout << "\nLet's get started!\n" << std::endl;
  std::srand(std::time(0));
  for (int i = 0; i < cargs.names.size(); i++)
  {
    Player play(cargs.names[i], LOOP);
    play.pprint(16);
  }
// конец нового кода
  return 0;
}

Здесь вызов srand необходим для инициализации генератора псевдослучайных чисел, этот вызов подкреплён соответствующими директивами препроцессора.

#include <cstdlib>
#include <ctime>

Сохраняю все изменения во всех трёх файлах, а затем перехожу в терминал и опять компилирую программу.

g++ -o bin/dice src/ddice.cpp src/dice.cpp

CHjB5krodI.png

Тестирую новый функционал.

0wpXmUtuoC.png

Как видно на снимке экрана выше, программа обрабатывает аргументы командной строки и выводит для каждого заданного игрока полученный им результат после броска кубиков. Блок для выравнивания на текущий момент задан явным образом константой - это минус, от которого я избавлюсь на следующем этапе разработки программы. Делаю очередной коммит в git-репозиторий.

Переходим к заключительной части марлезонского балета - игре и подведению итогов.

5. Описываем игру

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

  1. Бросить кости и найти максимальное количество баллов в игре;
  2. Вывести на экран игровые данные каждого участника игры;
  3. Найти участника или нескольких участников, которые набрали максимальное количество баллов и организовать для них второй тур, повторяя все действия до тех пор, пока не выявится единственный игрок, набравший максимальное количество баллов.

Для простого осуществления этих процедур я создам ещё один класс - Game. Открываю в редакторе заголовочный файл ddice.h и дописываю в рамках стандартной идиомы препроцессора ещё один прототип.

class Game
{
  private:
    std::vector<Player> players;
    unsigned int max;
    unsigned int block;
    unsigned int get_max();
    unsigned int fix_block();
  public:
    Game();
    Game(std::vector<std::string> & p, const unsigned int loop);
    ~Game() {}
    void count_scores(std::vector<std::string> & r);
};

В приватной части класс Game имеет свойство players, которое объединяет вектором всех участников игры - экземпляры класса Player. Кроме этого, свойства max и block определяют соответственно максимальное число полученных в игре баллов и ширину блока для выравнивания результатов при выводе на терминал. Для определения значений этих свойств здесь же заданы функции-члены get_max и fix_block.

В публичной части класса определены два конструктора и функция для подсчёта результатов игры - count_scores.

wM4ybTUJrk.png

Перехожу в редакторе на вкладку файла ddice.cpp и определяю для класса Game функции-члены.

Первый конструктор - дефолтный и не содержит параметров.

Game::Game()
{
  max = 0;
  block = 0;
}

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

Game::Game(std::vector<std::string> & p, const unsigned int loop)
{
  for (int i = 0; i < p.size(); i++)
    players.push_back(Player(p[i], loop));
  if (players.size() == 1)
    players.push_back(Player(loop));
  max = get_max();
  block = fix_block();
}

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

Когда все игроки созданы, определяются свойства max и block при помощи соответствующих методов get_max и fix_block. Определяю эти методы:

unsigned int Game::get_max()
{
  unsigned int m = 0;
  unsigned int t;
  for (int i = 0; i < players.size(); i++)
  {
    t = players[i].get_score();
    if (t > m)
      m = t;
  }
  return m;
}

unsigned int Game::fix_block()
{
  unsigned int b = 0;
  unsigned int t;
  for (int i = 0; i < players.size(); i++)
  {
    t = players[i].get_name().size();
    if (t > b)
      b = t;
  }
  return b + std::to_string(max).size() + 1;
}

Ширина блока для выравнивания определяется как сумма количества символов самого длинного имени игрока, количества символов числа, выражающего максимальное количество баллов игры, плюс единицы. Код обоих методов элементарен.

Последний метод, который необходимо определить - count_scores.

void Game::count_scores(std::vector<std::string> & r)
{
  if (!r.empty())
    r.clear();
  for (int i = 0; i < players.size(); i++)
  {
    players[i].pprint(block);
    if (players[i].get_score() == max)
      r.push_back(players[i].get_name());
  }
}

Здесь, в параметрах я передаю ссылку на вектор, состоящий из строк. В теле функции я проверяю, является ли переданный по ссылке вектор пустым, если это не так, удаляю из него все значения. Затем я выполняю в цикле следующие действия для каждого игрока в свойстве players:

  • вывожу на терминал данные игрока при помощи метода pprint класса Player, передав этому методу значение block в качестве параметра для выравнивания результатов на терминале;
  • проверяю, является ли набранное игроком количество баллов максимальным для данной игры и, если этот так, добавляю имя этого игрока в вектор r.

Имея в арсенале все разработанные инструменты, перехожу в редакторе на вкладку файла dice.cpp и редактирую соответствующим образом главную функцию программы.

int main(int argc, char ** argv)
{
  args cargs;
  parseArgs(argc, argv, cargs);
  std::cout << "Loop: " << cargs.loop << "\n\n";
  std::srand(std::time(0));
  std::vector<std::string> winners;
  Game game(cargs.names, cargs.loop);
  game.count_scores(winners);
  while (true)
  {
    if (winners.size() == 1)
    {
      std::cout << "\nCongrats " << winners[0] << "!" << std::endl;
      break;
    }
    else
    {
      std::cout << "\nNo winner, one more throw!\n" << std::endl;
      game = Game(winners, cargs.loop);
      game.count_scores(winners);
    }
  }
  return 0;
}

6. Подводим итоги

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

g++ -o bin/dice src/ddice.cpp src/dice.cpp

3IvmLk5Ckz.png

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

7JEKA16BBT.png

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

Aw82vd96Cv.png

Программа добавила в игру ещё одного участника с именем Machine - закономерный и ожидаемый результат.

Задача решена. Код программы можно найти в моём профиле на github.com.

Комментарии: